@blamejs/blamejs-shop 0.3.21 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.23 (2026-05-30) — **Fix page corruption from a dollar sign in content, keep search pages out of the index, and add a working unsubscribe link.** Three storefront fixes. First, a content-corruption bug: a dollar sign in any page's dynamic content — a product description, a blog post, a content page, a cart or admin page — could mangle the rendered HTML, and a dollar immediately followed by a backtick could splice the page's head markup into the body. Every page assembled its body in a way that gave dollar sequences special meaning; all of them now insert content verbatim. Second, internal search result pages are now marked noindex so the many query and filter URLs are not indexed as thin, duplicate pages (their product links are still followed). Third, the unsubscribe link that marketing emails point to now has a route behind it, so a recipient can actually unsubscribe (and resubscribe) instead of hitting a missing page. **Added:** *Newsletter unsubscribe link works* — A /unsubscribe route now backs the one-click unsubscribe link in marketing emails: opening the link shows a confirmation page, confirming removes the address, and a resubscribe option is offered. Invalid, expired, and already-used links each show a clear page rather than an error. The backend for this already existed; it simply had no route. **Fixed:** *A dollar sign in content no longer corrupts the page* — Pages were assembled by substituting the body into a template in a way that treated dollar sequences (such as a dollar followed by a backtick) as special, so content containing them could corrupt the output or pull head markup into the body. Every render path now inserts dynamic content literally, so any text — prices, code samples, anything with a dollar sign — renders exactly as written. · *Search result pages are no longer indexed* — Internal search result pages now carry a noindex robots tag so the large number of query and facet URL combinations aren't indexed as thin or duplicate content; the product links on those pages are still followed.
12
+
13
+ - v0.3.22 (2026-05-30) — **Create and publish content pages (About, Shipping, Returns, and the like) from the admin console.** The shop now has editable content pages. An admin Pages screen lets an operator create a page with a slug, title, Markdown body, layout, and meta fields, then publish, unpublish, archive, or restore it — and the storefront serves each published page at /pages/<slug>. Previously the content-page backend existed but had no admin screen and the storefront did not serve it, so it could not be used; a page is created as a draft and is not visible on the storefront until it is published. **Added:** *Content pages: author in the console, serve on the storefront* — A new Pages admin screen manages content pages through their full lifecycle (draft, published, archived, with restore). Published pages are served at /pages/<slug> with the page's chosen layout; a draft or archived slug returns not-found. The Markdown body is rendered with the same escaping and safe-link handling as the rest of the storefront. This makes a standard set of pages — about, shipping, returns, and similar — editable instead of requiring a code change.
14
+
11
15
  - v0.3.21 (2026-05-30) — **Customers can cancel an order from their account before it ships.** A signed-in customer can now cancel one of their own orders from the order page, as long as it has not started fulfillment. The Cancel control appears only while the order is still cancellable — a paid or pending order — and is absent once the order is fulfilling, shipped, delivered, already cancelled, or refunded. The action is tied to the signed-in customer, so a customer can only cancel their own order. Note: cancelling a paid order changes its status to cancelled but does not by itself refund the charge — issue the refund from the admin console as usual; a customer-initiated cancellation of a paid order is your signal to do so. **Added:** *Cancel an unfulfilled order from the account* — The order page now shows a Cancel button while an order is still in a pre-fulfillment state (paid or pending). Cancelling moves the order to cancelled and shows a confirmation; the control does not appear once an order is fulfilling, shipped, delivered, cancelled, or refunded, and a customer can only cancel their own order. A paid-order cancellation updates the status but does not automatically refund the captured payment — refund it from the console; treat a customer cancellation of a paid order as the prompt to do so.
12
16
 
13
17
  - v0.3.20 (2026-05-30) — **Served media carries hardened headers, primary-image changes are product-scoped, and image-by-URL imports are size-capped.** Three defense-in-depth fixes on product media. Media assets served from storage now carry X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, and SVG files additionally carry a content policy that prevents script from running when the file is opened directly while still rendering normally inside a product image. Setting a product's primary image now verifies the image actually belongs to the product named in the request rather than acting on the image alone. And importing a product image by URL now refuses a response larger than the same 10 MiB limit the file-upload path enforces, instead of buffering an unbounded download. **Changed:** *Setting the primary image is product-scoped* — The endpoint that makes an image a product's primary (hero) now verifies the image belongs to the product in the request path and returns not-found otherwise, instead of acting on the image regardless of the product named. A request whose product and image don't match is now rejected. · *Vendored runtime updated to v0.14.9* — The vendored blamejs runtime is updated to v0.14.9, a documentation-path and source-comment fix with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Hardened response headers on served media* — Media served from storage now sets X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, so a mistyped or hostile upload can't be content-sniffed into something executable or embedded cross-origin. An SVG additionally carries a default-src 'none' sandbox content-security policy, so opening an SVG URL directly cannot run script (it still renders inside an img tag). This sits behind the existing SVG-upload sanitizer as a second layer. · *Importing an image by URL is size-capped* — Importing a product image from a URL now refuses a response larger than 10 MiB — the same limit a direct file upload uses — returning a clear error instead of buffering an unbounded download.
package/lib/admin.js CHANGED
@@ -510,7 +510,7 @@ function mount(router, deps) {
510
510
  // `reports` is always present in the nav (read-only sales summary needs no
511
511
  // extra dep); its route mounts unconditionally and renders an unconfigured
512
512
  // notice when the salesReports primitive isn't wired.
513
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
513
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
514
514
 
515
515
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
516
516
 
@@ -4356,6 +4356,238 @@ function mount(router, deps) {
4356
4356
  _blogTransition("archive", function (s) { return blog.archive(s); });
4357
4357
  }
4358
4358
 
4359
+ // ---- storefront pages -----------------------------------------------
4360
+ // Author the operator's CMS content pages (About, Shipping, Returns,
4361
+ // Privacy, Terms, and the long tail every shop needs). The edge Worker
4362
+ // serves the customer-facing page at /pages/:slug, reading ONLY
4363
+ // published rows; this console writes them. A page is created as a draft
4364
+ // and stays invisible to the storefront until it's published — publish /
4365
+ // unpublish / archive / restore move it through the lifecycle (draft →
4366
+ // published → archived, with restore back to draft). Content-negotiated
4367
+ // like the other screens: bearer → the JSON contract; signed-in browser
4368
+ // → the HTML list + author forms.
4369
+ if (deps.storefrontPages) {
4370
+ var pages = deps.storefrontPages;
4371
+
4372
+ // The list pulls each lifecycle state separately (the primitive has
4373
+ // listDrafts / listArchived + listPublished, each returning an array)
4374
+ // and merges them for the console table. The console doesn't page the
4375
+ // page corpus — operators have a handful of content pages.
4376
+ async function _pageRows(filter) {
4377
+ var drafts = await pages.listDrafts();
4378
+ var archived = await pages.listArchived();
4379
+ var published = await pages.listPublished();
4380
+ if (filter === "draft") return drafts;
4381
+ if (filter === "published") return published;
4382
+ if (filter === "archived") return archived;
4383
+ // All: published first (newest-live), then drafts, then archived.
4384
+ return published.concat(drafts).concat(archived);
4385
+ }
4386
+
4387
+ router.get("/admin/pages", _pageOrApi(true,
4388
+ R(async function (req, res) {
4389
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4390
+ var status = (url && url.searchParams.get("status")) || null;
4391
+ _json(res, 200, { rows: await _pageRows(status) });
4392
+ }),
4393
+ async function (req, res) {
4394
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4395
+ var status = (url && url.searchParams.get("status")) || null;
4396
+ var rows = await _pageRows(status);
4397
+ _sendHtml(res, 200, renderAdminPages({
4398
+ shop_name: deps.shop_name, nav_available: navAvailable, pages: rows,
4399
+ status_filter: status,
4400
+ created: url && url.searchParams.get("created"),
4401
+ updated: url && url.searchParams.get("updated"),
4402
+ published: url && url.searchParams.get("published"),
4403
+ archived: url && url.searchParams.get("archived"),
4404
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the page." : null,
4405
+ }));
4406
+ },
4407
+ ));
4408
+
4409
+ // The new-page form. A standalone GET so the "New page" button on the
4410
+ // list has a target; the create POST lives at /admin/pages.
4411
+ router.get("/admin/pages/new", _pageOrApi(true,
4412
+ R(async function (_req, res) {
4413
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/pages with a JSON body to create a page");
4414
+ }),
4415
+ async function (_req, res) {
4416
+ _sendHtml(res, 200, renderAdminPageDetail({
4417
+ shop_name: deps.shop_name, nav_available: navAvailable, page: null,
4418
+ }));
4419
+ },
4420
+ ));
4421
+
4422
+ // Create content-negotiates: bearer → JSON 201 (a draft row); browser
4423
+ // form → defineDraft, then PRG to the page's detail screen to keep
4424
+ // authoring. Every page is born a DRAFT — never published on create —
4425
+ // so it can't reach the storefront before the operator publishes it.
4426
+ router.post("/admin/pages", _pageOrApi(false,
4427
+ W("page.create", async function (req, res) {
4428
+ var row = await pages.defineDraft(req.body || {});
4429
+ _json(res, 201, row);
4430
+ return { id: row.slug };
4431
+ }),
4432
+ async function (req, res) {
4433
+ var made;
4434
+ try {
4435
+ made = await pages.defineDraft(_pageFromForm(req.body || {}));
4436
+ } catch (e) {
4437
+ var n = _safeNotice(e, "page.create");
4438
+ return _sendHtml(res, n.status, renderAdminPageDetail({
4439
+ shop_name: deps.shop_name, nav_available: navAvailable, page: null,
4440
+ form_values: req.body || {},
4441
+ notice: n.message.replace(/^storefrontPages[.:]\s*/, ""),
4442
+ }));
4443
+ }
4444
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".page.create", outcome: "success", metadata: { slug: made.slug } });
4445
+ _redirect(res, "/admin/pages/" + encodeURIComponent(made.slug) + "?created=1");
4446
+ },
4447
+ ));
4448
+
4449
+ // Detail content-negotiates: bearer → JSON (the row, any status);
4450
+ // browser → the edit form + the lifecycle action buttons. A bad /
4451
+ // unknown slug is a 404 page, never a 500.
4452
+ router.get("/admin/pages/:slug", _pageOrApi(true,
4453
+ R(async function (req, res) {
4454
+ var row;
4455
+ try { row = await pages.get(req.params.slug); }
4456
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "page-not-found", e.message); throw e; }
4457
+ if (!row) return _problem(res, 404, "page-not-found");
4458
+ _json(res, 200, row);
4459
+ }),
4460
+ async function (req, res) {
4461
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4462
+ var row;
4463
+ try { row = await pages.get(req.params.slug); }
4464
+ catch (e) { if (!(e instanceof TypeError)) throw e; row = null; }
4465
+ if (!row) return _sendHtml(res, 404, renderAdminPages({
4466
+ shop_name: deps.shop_name, nav_available: navAvailable, pages: [], notice: "Page not found.",
4467
+ }));
4468
+ _sendHtml(res, 200, renderAdminPageDetail({
4469
+ shop_name: deps.shop_name, nav_available: navAvailable, page: row,
4470
+ updated: url && url.searchParams.get("updated"),
4471
+ published: url && url.searchParams.get("published"),
4472
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the page." : null,
4473
+ }));
4474
+ },
4475
+ ));
4476
+
4477
+ // Edit content-negotiates: bearer PATCH (the JSON contract) + browser
4478
+ // POST /edit (HTML forms can't PATCH). Both patch the editable columns
4479
+ // (title / body / meta / layout). Status is NOT editable here — it
4480
+ // moves via the lifecycle routes below.
4481
+ router.patch("/admin/pages/:slug", W("page.update", async function (req, res) {
4482
+ var row;
4483
+ try { row = await pages.update(req.params.slug, req.body || {}); }
4484
+ catch (e) {
4485
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4486
+ throw e;
4487
+ }
4488
+ _json(res, 200, row);
4489
+ return { id: row.slug };
4490
+ }));
4491
+
4492
+ router.post("/admin/pages/:slug/edit", _pageOrApi(false,
4493
+ W("page.update", async function (req, res) {
4494
+ var row;
4495
+ try { row = await pages.update(req.params.slug, req.body || {}); }
4496
+ catch (e) {
4497
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4498
+ throw e;
4499
+ }
4500
+ _json(res, 200, row);
4501
+ return { id: row.slug };
4502
+ }),
4503
+ async function (req, res) {
4504
+ var slug = req.params.slug;
4505
+ var enc = encodeURIComponent(slug);
4506
+ try {
4507
+ await pages.update(slug, _pagePatchFromForm(req.body || {}));
4508
+ } catch (e) {
4509
+ var n = _safeNotice(e, "page.update");
4510
+ var current = null;
4511
+ try { current = await pages.get(slug); } catch (_e) { current = null; }
4512
+ if (!current) return _redirect(res, "/admin/pages?err=1");
4513
+ return _sendHtml(res, n.status, renderAdminPageDetail({
4514
+ shop_name: deps.shop_name, nav_available: navAvailable, page: current,
4515
+ notice: n.message.replace(/^storefrontPages[.:]\s*/, ""),
4516
+ }));
4517
+ }
4518
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".page.update", outcome: "success", metadata: { slug: slug } });
4519
+ _redirect(res, "/admin/pages/" + enc + "?updated=1");
4520
+ },
4521
+ ));
4522
+
4523
+ // Lifecycle transitions — each PRGs back to the detail. publish takes
4524
+ // a draft live (now visible on the storefront); unpublish pulls it
4525
+ // back to draft (gone from the storefront); restore returns an
4526
+ // archived page to draft. An illegal transition (wrong from-state) or
4527
+ // a missing slug throws a TypeError from the primitive — a ?err=1
4528
+ // notice, never a 500.
4529
+ function _pageTransition(action, fn) {
4530
+ router.post("/admin/pages/:slug/" + action, _pageOrApi(false,
4531
+ W("page." + action, async function (req, res) {
4532
+ var row;
4533
+ try { row = await fn(req.params.slug); }
4534
+ catch (e) {
4535
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4536
+ throw e;
4537
+ }
4538
+ _json(res, 200, row);
4539
+ return { id: row.slug };
4540
+ }),
4541
+ async function (req, res) {
4542
+ var slug = req.params.slug;
4543
+ var enc = encodeURIComponent(slug);
4544
+ try {
4545
+ await fn(slug);
4546
+ } catch (e) {
4547
+ if (e instanceof TypeError) return _redirect(res, "/admin/pages/" + enc + "?err=1");
4548
+ throw e;
4549
+ }
4550
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".page." + action, outcome: "success", metadata: { slug: slug } });
4551
+ // archive lands back on the list (the page left the editable
4552
+ // set); the others stay on the detail with a status banner.
4553
+ if (action === "archive") return _redirect(res, "/admin/pages?archived=1");
4554
+ _redirect(res, "/admin/pages/" + enc + (action === "publish" ? "?published=1" : "?updated=1"));
4555
+ },
4556
+ ));
4557
+ }
4558
+ _pageTransition("publish", function (s) { return pages.publish(s); });
4559
+ _pageTransition("unpublish", function (s) { return pages.unpublish(s); });
4560
+ _pageTransition("restore", function (s) { return pages.restore(s); });
4561
+
4562
+ // Archive removes the page from the live storefront and the editable
4563
+ // set, so the browser path confirms first — the CSP forbids a client
4564
+ // confirm() dialog. Reached by a GET link from the detail screen;
4565
+ // bearer clients POST /archive directly.
4566
+ router.get("/admin/pages/:slug/archive/confirm-page", _pageOrApi(true,
4567
+ R(async function (_req, res) {
4568
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/pages/:slug/archive directly for the JSON API");
4569
+ }),
4570
+ async function (req, res) {
4571
+ var slug = req.params.slug;
4572
+ var enc = encodeURIComponent(slug);
4573
+ var row = null;
4574
+ try { row = await pages.get(slug); }
4575
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
4576
+ if (!row) return _redirect(res, "/admin/pages?err=1");
4577
+ _sendHtml(res, 200, renderAdminConfirm({
4578
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "pages",
4579
+ heading: "Archive this page?",
4580
+ consequence: "Archiving removes the page from the live storefront. You can restore it to a draft later.",
4581
+ detail: "Page: " + (row.title || slug) + ".",
4582
+ action: "/admin/pages/" + _htmlEscape(enc) + "/archive",
4583
+ confirm_label: "Archive page",
4584
+ cancel_href: "/admin/pages/" + enc,
4585
+ }));
4586
+ },
4587
+ ));
4588
+ _pageTransition("archive", function (s) { return pages.archive(s); });
4589
+ }
4590
+
4359
4591
  // ---- customer surveys -----------------------------------------------
4360
4592
  // Define NPS/CSAT/CES/custom surveys, issue token invitations (the
4361
4593
  // plaintext link is shown once on issue), and read the rollup. The
@@ -6692,6 +6924,21 @@ function _renderTemplate(template, vars) {
6692
6924
  return out;
6693
6925
  }
6694
6926
 
6927
+ // Splice a fully-rendered HTML fragment into `html` at the first
6928
+ // occurrence of a literal `RAW_*` token, inserting the fragment LITERALLY.
6929
+ //
6930
+ // `String.prototype.replace(token, replacementString)` gives the
6931
+ // replacement string special meaning to `$` sequences — `$$`, `$&`,
6932
+ // `` $` `` (the text before the match), `$'` (the text after the match),
6933
+ // `$N`. An admin page body that contains a dollar followed by a backtick
6934
+ // would otherwise splice the page chrome into the body, and any other
6935
+ // dollar sequence corrupts the output. Passing a REPLACER FUNCTION makes
6936
+ // `String.replace` insert the return value verbatim, with no dollar
6937
+ // interpretation. Mirrors the storefront's `_spliceRaw`.
6938
+ function _spliceRaw(html, token, fragment) {
6939
+ return html.replace(token, function () { return fragment == null ? "" : String(fragment); });
6940
+ }
6941
+
6695
6942
  function _sparkSvg(byDay, currency) {
6696
6943
  // SVG sparkline rendered server-side from revenue-by-day rows of
6697
6944
  // the dashboard's primary currency. Returns an empty placeholder
@@ -6844,6 +7091,7 @@ var ADMIN_NAV_ITEMS = [
6844
7091
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
6845
7092
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
6846
7093
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
7094
+ { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
6847
7095
  { key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
6848
7096
  { key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
6849
7097
  { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
@@ -6873,8 +7121,10 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
6873
7121
  nav: "RAW_NAV",
6874
7122
  body: "RAW_BODY",
6875
7123
  }).replace("RAW_ADMIN_CSS", _adminStylesheetLink())
6876
- .replace("RAW_NAV", _adminNav(active, available))
6877
- .replace("RAW_BODY", bodyHtml);
7124
+ .replace("RAW_NAV", _adminNav(active, available));
7125
+ // Splice the admin body literally so a `$`-bearing fragment can't trip
7126
+ // `String.replace`'s dollar substitution. See `_spliceRaw`.
7127
+ html = _spliceRaw(html, "RAW_BODY", bodyHtml);
6878
7128
  // Token every admin POST form with the per-request double-submit CSRF value
6879
7129
  // (seeded on the ALS by mount()'s sync middleware). Single funnel — every
6880
7130
  // authenticated admin page assembles here — so this is the one place the
@@ -9961,6 +10211,203 @@ function renderAdminBlogDetail(opts) {
9961
10211
  return _renderAdminShell(opts.shop_name, isNew ? "New post" : "Post " + a.slug, body, "blog", opts.nav_available);
9962
10212
  }
9963
10213
 
10214
+ // Coerce the create form into storefrontPages.defineDraft's shape: trimmed
10215
+ // slug, the required title/body, and the optional meta + layout fields (a
10216
+ // blank optional field is omitted so the primitive's default applies). The
10217
+ // primitive validates every field — this only shapes the form strings.
10218
+ function _pageFromForm(body) {
10219
+ body = body || {};
10220
+ var out = {
10221
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
10222
+ title: body.title,
10223
+ body: body.body,
10224
+ };
10225
+ var md = typeof body.meta_description === "string" ? body.meta_description.trim() : "";
10226
+ if (md) out.meta_description = md;
10227
+ var mk = typeof body.meta_keywords === "string" ? body.meta_keywords.trim() : "";
10228
+ if (mk) out.meta_keywords = mk;
10229
+ var layout = typeof body.layout === "string" ? body.layout.trim() : "";
10230
+ if (layout) out.layout = layout;
10231
+ return out;
10232
+ }
10233
+
10234
+ // Coerce the edit form into a storefrontPages.update patch. Only the
10235
+ // columns the form carries are included; an empty optional meta field is
10236
+ // sent as "" so the operator can clear a previously-set meta line (the
10237
+ // primitive accepts an empty string for the nullable meta columns). Status
10238
+ // / slug are NOT here — status moves via the lifecycle routes; the slug is
10239
+ // the PK and immutable after create.
10240
+ function _pagePatchFromForm(body) {
10241
+ body = body || {};
10242
+ var patch = {};
10243
+ if (typeof body.title === "string") patch.title = body.title;
10244
+ if (typeof body.body === "string") patch.body = body.body;
10245
+ if (typeof body.meta_description === "string") patch.meta_description = body.meta_description.trim();
10246
+ if (typeof body.meta_keywords === "string") patch.meta_keywords = body.meta_keywords.trim();
10247
+ if (typeof body.layout === "string" && body.layout.trim()) patch.layout = body.layout.trim();
10248
+ return patch;
10249
+ }
10250
+
10251
+ // One status pill class per lifecycle state, reusing the order-status pill
10252
+ // palette: published = paid (green), draft = pending (amber), archived =
10253
+ // cancelled (grey). Mirrors the blog pill so the two content surfaces read
10254
+ // the same.
10255
+ function _pageStatusPill(status) {
10256
+ var cls = status === "published" ? "paid" : status === "archived" ? "cancelled" : "pending";
10257
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
10258
+ }
10259
+
10260
+ // Layout <select> — the closed enum the migration + primitive enforce.
10261
+ // `current` pre-selects the page's layout (default when absent).
10262
+ var _PAGE_LAYOUTS = ["default", "wide", "landing", "legal"];
10263
+ function _pageLayoutSelect(current) {
10264
+ var sel = (typeof current === "string" && _PAGE_LAYOUTS.indexOf(current) !== -1) ? current : "default";
10265
+ var opts = _PAGE_LAYOUTS.map(function (l) {
10266
+ return "<option value=\"" + l + "\"" + (l === sel ? " selected" : "") + ">" + l + "</option>";
10267
+ }).join("");
10268
+ return "<label class=\"form-field\"><span>Layout</span>" +
10269
+ "<select name=\"layout\">" + opts + "</select>" +
10270
+ "<small>Picks the storefront wrapper: default, wide, landing, or legal.</small></label>";
10271
+ }
10272
+
10273
+ function renderAdminPages(opts) {
10274
+ opts = opts || {};
10275
+ var rows = opts.pages || [];
10276
+ var created = opts.created ? "<div class=\"banner banner--ok\">Page created as a draft.</div>" : "";
10277
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Page saved.</div>" : "";
10278
+ var published = opts.published ? "<div class=\"banner banner--ok\">Page published — it's live on the storefront.</div>" : "";
10279
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Page archived.</div>" : "";
10280
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
10281
+
10282
+ var sf = opts.status_filter;
10283
+ var chips = "<div class=\"order-filters\">" +
10284
+ "<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/pages\">All</a>" +
10285
+ "<a class=\"chip" + (sf === "published" ? " chip--on" : "") + "\" href=\"/admin/pages?status=published\">Published</a>" +
10286
+ "<a class=\"chip" + (sf === "draft" ? " chip--on" : "") + "\" href=\"/admin/pages?status=draft\">Drafts</a>" +
10287
+ "<a class=\"chip" + (sf === "archived" ? " chip--on" : "") + "\" href=\"/admin/pages?status=archived\">Archived</a>" +
10288
+ "</div>";
10289
+
10290
+ var bodyRows = rows.map(function (p) {
10291
+ var enc = encodeURIComponent(p.slug);
10292
+ var date = p.published_at != null ? _fmtDate(p.published_at) : "—";
10293
+ // Per-row lifecycle actions match the page's current state: a draft
10294
+ // can publish; a published page can unpublish or archive; an archived
10295
+ // page can restore. Edit is always offered.
10296
+ var actions = "<a class=\"btn btn--ghost\" href=\"/admin/pages/" + _htmlEscape(enc) + "\">Edit</a>";
10297
+ if (p.status === "draft") {
10298
+ actions += "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
10299
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>";
10300
+ } else if (p.status === "published") {
10301
+ actions += "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
10302
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
10303
+ "<a class=\"btn btn--danger\" href=\"/admin/pages/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>";
10304
+ } else if (p.status === "archived") {
10305
+ actions += "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
10306
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>";
10307
+ }
10308
+ return "<tr>" +
10309
+ "<td><a href=\"/admin/pages/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(p.title) + "</strong></a></td>" +
10310
+ "<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
10311
+ "<td>" + _pageStatusPill(p.status) + "</td>" +
10312
+ "<td>" + _htmlEscape(date) + "</td>" +
10313
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
10314
+ "</tr>";
10315
+ }).join("");
10316
+
10317
+ var table = rows.length
10318
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th><th scope=\"col\">Published</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
10319
+ : "<p class=\"empty\">No pages" + (sf ? " " + _htmlEscape(sf) : " yet") + ". Write your first one.</p>";
10320
+
10321
+ var newBtn = "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/pages/new\">New page</a></div>";
10322
+
10323
+ var bodyHtml = "<section><h2>Pages</h2>" +
10324
+ created + updated + published + archived + notice +
10325
+ "<p class=\"meta\">Content pages shown on the storefront at /pages/&lt;slug&gt; (About, Shipping, Returns, and the like). A page is created as a draft and stays hidden until you publish it.</p>" +
10326
+ newBtn + chips + table + "</section>";
10327
+ return _renderAdminShell(opts.shop_name, "Pages", bodyHtml, "pages", opts.nav_available);
10328
+ }
10329
+
10330
+ // New-page form (page: null) or edit form (page set) for a single content
10331
+ // page, plus the lifecycle action row when editing an existing page.
10332
+ // `form_values` re-fills a failed create so the operator doesn't retype.
10333
+ function renderAdminPageDetail(opts) {
10334
+ opts = opts || {};
10335
+ var p = opts.page || null;
10336
+ var isNew = !p;
10337
+ var fv = opts.form_values || {};
10338
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Page saved.</div>" : "";
10339
+ var published = opts.published ? "<div class=\"banner banner--ok\">Page published — it's live on the storefront.</div>" : "";
10340
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
10341
+
10342
+ // Field values: the existing row when editing, else the failed-create
10343
+ // form values, else blank.
10344
+ function _val(col) {
10345
+ if (p && p[col] != null) return p[col];
10346
+ if (fv[col] != null) return fv[col];
10347
+ return "";
10348
+ }
10349
+
10350
+ var action = isNew ? "/admin/pages" : "/admin/pages/" + encodeURIComponent(p.slug) + "/edit";
10351
+ // Slug is the PK — editable only on create (the primitive keys update on
10352
+ // it). On edit it shows read-only so the operator sees the storefront URL.
10353
+ var slugField = isNew
10354
+ ? _setupField("Slug", "slug", _val("slug"), "text", "Letters, digits, dots, hyphens, underscores — appears in /pages/<slug>.", " maxlength=\"80\" required")
10355
+ : "<label class=\"form-field\"><span>Slug</span><input type=\"text\" value=\"" + _htmlEscape(p.slug) + "\" readonly>" +
10356
+ "<small>The storefront URL: <code>/pages/" + _htmlEscape(p.slug) + "</code></small></label>";
10357
+
10358
+ var form =
10359
+ "<div class=\"panel mw-40\">" +
10360
+ "<h3 class=\"subhead\">" + (isNew ? "New page" : "Page details") + "</h3>" +
10361
+ "<form method=\"post\" action=\"" + _htmlEscape(action) + "\">" +
10362
+ slugField +
10363
+ _setupField("Title", "title", _val("title"), "text", "Shown as the page heading and the <title> tag.", " maxlength=\"200\" required") +
10364
+ "<label class=\"form-field\"><span>Body (Markdown)</span>" +
10365
+ "<textarea name=\"body\" rows=\"18\" maxlength=\"200000\" required>" + _htmlEscape(_val("body")) + "</textarea>" +
10366
+ "<small>Headings, lists, links, bold/italic. Raw HTML is escaped — links are https-only or /-rooted.</small></label>" +
10367
+ _pageLayoutSelect(p ? p.layout : (typeof fv.layout === "string" ? fv.layout : "default")) +
10368
+ _setupField("Meta description (optional)", "meta_description", _val("meta_description"), "text", "Shown in search results + the page's <head>.", " maxlength=\"320\"") +
10369
+ _setupField("Meta keywords (optional)", "meta_keywords", _val("meta_keywords"), "text", "", " maxlength=\"320\"") +
10370
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">" + (isNew ? "Create draft" : "Save") + "</button>" +
10371
+ "<a class=\"btn btn--ghost\" href=\"/admin/pages\">Back</a></div>" +
10372
+ "</form>" +
10373
+ "</div>";
10374
+
10375
+ var lifecycle = "";
10376
+ if (!isNew) {
10377
+ var enc = encodeURIComponent(p.slug);
10378
+ var btns = "";
10379
+ if (p.status === "draft") {
10380
+ btns = "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
10381
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>" +
10382
+ "<span class=\"meta\">Publishing makes the page live on the storefront.</span>";
10383
+ } else if (p.status === "published") {
10384
+ btns = "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
10385
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
10386
+ "<a class=\"btn btn--danger\" href=\"/admin/pages/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>" +
10387
+ "<span class=\"meta\">Unpublish pulls it back to a draft; archive removes it from the storefront.</span>";
10388
+ } else if (p.status === "archived") {
10389
+ btns = "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
10390
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore to draft</button></form>";
10391
+ }
10392
+ lifecycle = "<div class=\"panel mt-1 mw-40\"><h3 class=\"subhead\">Status</h3>" +
10393
+ "<p class=\"meta\">Current state: " + _pageStatusPill(p.status) + "</p>" +
10394
+ "<div class=\"actions-row\">" + btns + "</div></div>";
10395
+ }
10396
+
10397
+ var head = isNew
10398
+ ? "<p class=\"meta\"><a href=\"/admin/pages\">&larr; Pages</a></p>"
10399
+ : "<p class=\"meta\"><a href=\"/admin/pages\">&larr; Pages</a> · " +
10400
+ _pageStatusPill(p.status) +
10401
+ (p.status === "published"
10402
+ ? " · <a href=\"/pages/" + _htmlEscape(encodeURIComponent(p.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a>"
10403
+ : "") +
10404
+ "</p>";
10405
+
10406
+ var title = isNew ? "New page" : (p.title || p.slug);
10407
+ var body = "<section><h2>" + _htmlEscape(title) + "</h2>" + updated + published + notice + head + form + "</section>" + lifecycle;
10408
+ return _renderAdminShell(opts.shop_name, isNew ? "New page" : "Page " + p.slug, body, "pages", opts.nav_available);
10409
+ }
10410
+
9964
10411
  // Standard question set per survey kind, so the console can create the
9965
10412
  // common surveys without a dynamic question builder (fully custom question
9966
10413
  // lists go through the JSON defineSurvey API). Each question carries a
@@ -10781,6 +11228,8 @@ module.exports = {
10781
11228
  renderAdminCollection: renderAdminCollection,
10782
11229
  renderAdminBlog: renderAdminBlog,
10783
11230
  renderAdminBlogDetail: renderAdminBlogDetail,
11231
+ renderAdminPages: renderAdminPages,
11232
+ renderAdminPageDetail: renderAdminPageDetail,
10784
11233
  renderAdminGiftCards: renderAdminGiftCards,
10785
11234
  renderAdminGiftCard: renderAdminGiftCard,
10786
11235
  renderAdminTaxRates: renderAdminTaxRates,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.21",
2
+ "version": "0.3.23",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -105,6 +105,13 @@ var EDGE_POST_PATHS = [
105
105
  "/consent",
106
106
  "/currency",
107
107
  "/newsletter",
108
+ // One-click unsubscribe (RFC 8058). The unsubscribe token in the URL IS
109
+ // the bearer; the POST a mail client fires from List-Unsubscribe-Post
110
+ // carries no cookies and no session CSRF token, so the path must be
111
+ // exempt from the double-submit check (it keeps its SameSite +
112
+ // fetch-metadata defense and is single-use + timing-safe at the token
113
+ // layer). Container-only — there's no edge copy to keep parity with.
114
+ "/unsubscribe",
108
115
  "/announcements/",
109
116
  ];
110
117
 
package/lib/storefront.js CHANGED
@@ -106,6 +106,23 @@ function webhookRawBodyCapture(paths) {
106
106
  // same XSS guard, same unknown / unused refusal).
107
107
  var _render = emailModule._render;
108
108
 
109
+ // Splice a fully-rendered HTML fragment into `html` at the first
110
+ // occurrence of a literal `RAW_*` token, inserting the fragment LITERALLY.
111
+ //
112
+ // `String.prototype.replace(token, replacementString)` gives the
113
+ // replacement string special meaning to `$` sequences — `$$`, `$&`,
114
+ // `` $` `` (the text before the match), `$'` (the text after the match),
115
+ // `$1`. A page/blog body that contains a dollar followed by a backtick
116
+ // would otherwise splice the entire document HEAD into the body, and any
117
+ // other dollar sequence corrupts the output. The fragment is already
118
+ // escaped/rendered at its own build site, so this is purely about the
119
+ // replace mechanics: passing a REPLACER FUNCTION makes `String.replace`
120
+ // insert the return value verbatim, with no dollar interpretation. Use
121
+ // this for every dynamic (operator- or customer-supplied) fragment swap.
122
+ function _spliceRaw(html, token, fragment) {
123
+ return html.replace(token, function () { return fragment == null ? "" : String(fragment); });
124
+ }
125
+
109
126
  // ---- double-submit CSRF token injection ---------------------------------
110
127
 
111
128
  // The container's authenticated state-changing forms (account /
@@ -758,15 +775,23 @@ function _wrap(opts) {
758
775
 
759
776
  // Per-page robots directive. Indexable pages (the default) emit no
760
777
  // robots meta — the absence means "index, follow". Pages the storefront
761
- // keeps out of search (cart / account / any session-scoped surface)
762
- // pass `robots: "noindex"`, which stamps a noindex,nofollow meta in the
763
- // <head> a belt-and-suspenders pairing with the robots.txt Disallow +
764
- // the edge `x-robots-tag` header, so a directly-fetched page is also
778
+ // keeps out of search pass a directive:
779
+ // - `noindex` `noindex,nofollow`. Session-scoped surfaces (cart /
780
+ // account) whose links are all session-scoped too, so there's
781
+ // nothing for a crawler to follow.
782
+ // - `noindex,follow` → `noindex,follow`. Internal search result pages
783
+ // are thin/duplicate indexable URLs (one per query + facet combo) —
784
+ // keep the query URL out of the index but let crawlers follow the
785
+ // product links the page lists.
786
+ // It's a belt-and-suspenders pairing with the robots.txt Disallow + the
787
+ // edge `x-robots-tag` header, so a directly-fetched page is also
765
788
  // self-describing. A noindex page needs no canonical (a crawler won't
766
789
  // index it to dedupe), so these pages don't thread a canonical_url.
767
- var robotsMeta = (opts.robots === "noindex")
768
- ? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
769
- : "";
790
+ var robotsMeta = (opts.robots === "noindex,follow")
791
+ ? " <meta name=\"robots\" content=\"noindex,follow\">\n"
792
+ : (opts.robots === "noindex")
793
+ ? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
794
+ : "";
770
795
 
771
796
  var assembled = _render(LAYOUT, vars)
772
797
  .replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
@@ -776,15 +801,18 @@ function _wrap(opts) {
776
801
  .replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
777
802
  .replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
778
803
  .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
779
- .replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
780
- .replace("RAW_BODY_PLACEHOLDER", opts.body);
804
+ .replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "");
781
805
  // The body is RAW HTML (already rendered + escaped at the
782
806
  // per-fragment level). The placeholder swap is post-render so the
783
807
  // outer renderer's HTML-escape doesn't double-escape the inner
784
- // markup. `search_q` is HTML-escaped by the renderer like any
808
+ // markup, and routes through `_spliceRaw` so a body carrying a `$`
809
+ // sequence (a blog/CMS post, a reflected query) is inserted literally
810
+ // rather than triggering `String.replace`'s dollar-substitution.
811
+ // `search_q` is HTML-escaped by the renderer like any
785
812
  // other placeholder, so a customer-supplied query like
786
813
  // `"><script>` lands as escaped text inside the input's `value`.
787
814
  //
815
+ assembled = _spliceRaw(assembled, "RAW_BODY_PLACEHOLDER", opts.body);
788
816
  // Final pass: token the container's authenticated POST forms with the
789
817
  // per-request double-submit CSRF value (stashed on the locale ALS by
790
818
  // `localeMiddleware`). EDGE_POST_PATHS forms are skipped to preserve
@@ -1557,6 +1585,10 @@ function renderSearch(opts) {
1557
1585
  og_description: searchMetaDescription,
1558
1586
  canonical_url: opts.canonical_url,
1559
1587
  og_url: opts.og_url,
1588
+ // Internal search results are thin/duplicate indexable URLs (one per
1589
+ // query + facet combination) — keep the query URL out of the index
1590
+ // but let crawlers follow the product links the page lists.
1591
+ robots: "noindex,follow",
1560
1592
  body: body,
1561
1593
  }, _currencyWrapOpts(opts)));
1562
1594
  }
@@ -5226,6 +5258,110 @@ function renderNewsletterError(opts) {
5226
5258
  });
5227
5259
  }
5228
5260
 
5261
+ // ---- newsletter unsubscribe (CAN-SPAM / RFC 8058) ----------------------
5262
+ //
5263
+ // A marketing email's unsubscribe link points at GET /unsubscribe?token=…
5264
+ // (a friendly confirm page) and List-Unsubscribe-Post fires a one-click
5265
+ // POST /unsubscribe with the same token. Both are unauthenticated — the
5266
+ // opaque, single-use, timing-safe token is the bearer — and CSRF-exempt
5267
+ // (the mail-client POST carries no session). The token is never echoed
5268
+ // back to the page; only its structured outcome is rendered.
5269
+
5270
+ // The confirm page (GET). A no-JS form POSTs the token back to
5271
+ // /unsubscribe to perform the unsubscribe, so a visitor who followed the
5272
+ // link sees a deliberate "yes, unsubscribe me" step rather than being
5273
+ // unsubscribed by a link-prefetcher. The token rides in a hidden field
5274
+ // (HTML-escaped) and is the only handle — no email address is shown.
5275
+ function renderUnsubscribeConfirm(opts) {
5276
+ opts = opts || {};
5277
+ var token = typeof opts.token === "string" ? opts.token : "";
5278
+ var body =
5279
+ "<section class=\"newsletter-thanks\">" +
5280
+ "<div class=\"newsletter-thanks__card\">" +
5281
+ "<p class=\"eyebrow\">Newsletter</p>" +
5282
+ "<h1 class=\"newsletter-thanks__title\">Unsubscribe from the list?</h1>" +
5283
+ "<p class=\"newsletter-thanks__lede\">Confirm below and we'll stop emailing this address. You can re-subscribe any time from the footer of the site.</p>" +
5284
+ "<form class=\"newsletter-unsub__form\" method=\"post\" action=\"/unsubscribe\">" +
5285
+ "<input type=\"hidden\" name=\"token\" value=\"" + _escAttr(token) + "\">" +
5286
+ "<div class=\"newsletter-thanks__cta\">" +
5287
+ "<button type=\"submit\" class=\"btn-primary\">Unsubscribe</button>" +
5288
+ "<a href=\"/\" class=\"btn-ghost\">Keep me subscribed</a>" +
5289
+ "</div>" +
5290
+ "</form>" +
5291
+ "</div>" +
5292
+ "</section>";
5293
+ return _wrap({
5294
+ title: "Unsubscribe",
5295
+ shop_name: opts.shop_name || "blamejs.shop",
5296
+ cart_count: opts.cart_count,
5297
+ theme_css: opts.theme_css,
5298
+ // The confirm + result pages are token-bearer surfaces with no
5299
+ // canonical/indexable identity — keep them out of search (`noindex`
5300
+ // maps to noindex,nofollow in _wrap).
5301
+ robots: "noindex",
5302
+ body: body,
5303
+ });
5304
+ }
5305
+
5306
+ // The POST outcome page. `opts.outcome` is one of the structured results
5307
+ // the unsubscribe flow produces: "unsubscribed" (success — offers a
5308
+ // re-subscribe form), "not-found" (invalid / unknown token), "already"
5309
+ // (the token was already consumed — the address is already off the list),
5310
+ // "expired" (the token's TTL lapsed). Every branch renders a clean,
5311
+ // server-rendered page; no raw error is ever surfaced.
5312
+ function renderUnsubscribeResult(opts) {
5313
+ opts = opts || {};
5314
+ var outcome = opts.outcome;
5315
+ var heading, lede, extra = "";
5316
+ if (outcome === "unsubscribed") {
5317
+ heading = "You're unsubscribed.";
5318
+ lede = "This address won't receive any more newsletter email. Changed your mind? Re-subscribe below.";
5319
+ // Re-subscribe affordance — the storefront newsletter signup form,
5320
+ // pre-pointed at the footer band's POST /newsletter so a single click
5321
+ // (after typing the address) puts the visitor back on the list.
5322
+ extra =
5323
+ "<form class=\"newsletter-resub__form\" method=\"post\" action=\"/newsletter\">" +
5324
+ "<label class=\"skip-link\" for=\"resub-email\">Email address</label>" +
5325
+ "<input id=\"resub-email\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">" +
5326
+ "<button type=\"submit\" class=\"btn-primary\">Re-subscribe</button>" +
5327
+ "</form>";
5328
+ } else if (outcome === "already") {
5329
+ heading = "Already unsubscribed.";
5330
+ lede = "This link was already used — the address is off the list, so there's nothing more to do.";
5331
+ } else if (outcome === "expired") {
5332
+ heading = "That link has expired.";
5333
+ lede = "Unsubscribe links are valid for a limited time. You're still on the list; use the unsubscribe link in a more recent email, or contact us and we'll remove you.";
5334
+ } else {
5335
+ // "not-found" + any unexpected code → the same generic, non-leaking
5336
+ // copy. A bad / unknown / malformed token can't be distinguished from
5337
+ // outside, by design.
5338
+ heading = "This link isn't valid.";
5339
+ lede = "We couldn't match this unsubscribe link to a subscription. It may have already been used, or the link may be incomplete.";
5340
+ }
5341
+ var body =
5342
+ "<section class=\"newsletter-thanks\">" +
5343
+ "<div class=\"newsletter-thanks__card\">" +
5344
+ "<p class=\"eyebrow\">Newsletter</p>" +
5345
+ "<h1 class=\"newsletter-thanks__title\">" + heading + "</h1>" +
5346
+ "<p class=\"newsletter-thanks__lede\">" + lede + "</p>" +
5347
+ extra +
5348
+ "<div class=\"newsletter-thanks__cta\">" +
5349
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
5350
+ "</div>" +
5351
+ "</div>" +
5352
+ "</section>";
5353
+ return _wrap({
5354
+ title: "Unsubscribe",
5355
+ shop_name: opts.shop_name || "blamejs.shop",
5356
+ cart_count: opts.cart_count,
5357
+ theme_css: opts.theme_css,
5358
+ // Token-bearer outcome page — keep it out of search (`noindex` maps to
5359
+ // noindex,nofollow in _wrap).
5360
+ robots: "noindex",
5361
+ body: body,
5362
+ });
5363
+ }
5364
+
5229
5365
  // ---- cookie preference center ------------------------------------------
5230
5366
 
5231
5367
  // The four toggleable categories + their operator-facing copy, mirroring
@@ -11263,6 +11399,71 @@ function mount(router, deps) {
11263
11399
  }));
11264
11400
  }
11265
11401
  });
11402
+
11403
+ // Newsletter unsubscribe (CAN-SPAM one-click / RFC 8058).
11404
+ //
11405
+ // GET /unsubscribe?token=… renders a friendly confirm page; the
11406
+ // visitor clicks "Unsubscribe", which POSTs the token back. POST
11407
+ // /unsubscribe is also the List-Unsubscribe-Post one-click target a
11408
+ // mail client fires directly. The opaque, single-use, timing-safe
11409
+ // token is the bearer — no session, no auth — so the route is
11410
+ // CSRF-exempt (see EDGE_POST_PATHS) and reads each structured outcome
11411
+ // `consumeUnsubscribeToken` returns onto a clean server-rendered page.
11412
+ // The token is never echoed back beyond the confirm form's hidden
11413
+ // field; the outcome page reveals nothing about whether a token
11414
+ // existed, was already used, or expired beyond its own friendly copy.
11415
+
11416
+ // Map a `consumeUnsubscribeToken` result code onto the outcome the
11417
+ // result page renders. The structured codes are stable; an unknown
11418
+ // code degrades to the generic "not valid" page rather than a 500.
11419
+ function _unsubscribeOutcome(result) {
11420
+ if (result && result.ok === true) return "unsubscribed";
11421
+ var code = result && result.error;
11422
+ if (code === "already-consumed") return "already";
11423
+ if (code === "expired") return "expired";
11424
+ return "not-found"; // "not-found" + anything unexpected
11425
+ }
11426
+
11427
+ router.get("/unsubscribe", async function (req, res) {
11428
+ var token = (req.query && typeof req.query.token === "string") ? req.query.token : "";
11429
+ var cartCount = 0;
11430
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
11431
+ return _send(res, 200, renderUnsubscribeConfirm({
11432
+ shop_name: shopName,
11433
+ cart_count: cartCount,
11434
+ token: token,
11435
+ }));
11436
+ });
11437
+
11438
+ router.post("/unsubscribe", async function (req, res) {
11439
+ var body = req.body || {};
11440
+ var token = typeof body.token === "string" ? body.token : "";
11441
+ var cartCount = 0;
11442
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
11443
+ var outcome;
11444
+ try {
11445
+ // `consumeUnsubscribeToken` returns a structured result (it does
11446
+ // not throw on a bad/missing token — it returns `{ ok:false,
11447
+ // error:"not-found" }`). An empty token is handled the same way.
11448
+ var result = await deps.newsletter.consumeUnsubscribeToken(token);
11449
+ outcome = _unsubscribeOutcome(result);
11450
+ } catch (e) {
11451
+ // A real infrastructure fault (D1 unreachable) — record it server-
11452
+ // side and render the generic non-leaking page rather than a 500
11453
+ // that exposes internals on a public, unauthenticated route.
11454
+ _log.error("storefront unsubscribe failed", {
11455
+ route: "/unsubscribe",
11456
+ request_id: (req && req.requestId) || null,
11457
+ err: (e && e.message) || String(e),
11458
+ });
11459
+ outcome = "not-found";
11460
+ }
11461
+ return _send(res, 200, renderUnsubscribeResult({
11462
+ shop_name: shopName,
11463
+ cart_count: cartCount,
11464
+ outcome: outcome,
11465
+ }));
11466
+ });
11266
11467
  }
11267
11468
 
11268
11469
  // ---- cookie consent -----------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {