@blamejs/blamejs-shop 0.2.17 → 0.2.19

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.2.x
10
10
 
11
+ - v0.2.19 (2026-05-29) — **Search results render the same product cards as the rest of the store.** A product with no image showed an outdated, text-only card on the search-results page when that page was served from the edge, while the home and category grids (and the origin-rendered search page) used the current placeholder-illustration card. The edge search page now emits the same card markup as every other grid, so a no-image product looks identical wherever it appears. A render-level parity check now pins the search page's markup byte-for-byte across the edge and origin paths so this kind of drift is caught automatically. **Fixed:** *Consistent no-image product card on search* — The edge-rendered search results page used a stale text-only card for products without a hero image; it now renders the placeholder-illustration card used across the rest of the storefront, byte-identical to the origin render.
12
+
13
+ - v0.2.18 (2026-05-29) — **Admin: a clear "payments aren't live" warning, and void a gift card.** Two admin-console fixes. The landing page now warns that payments aren't live whenever Stripe isn't configured — independently of the setup wizard — so an operator who finishes the identity wizard isn't misled into thinking customers can check out when they still can't. And gift cards can now be voided from the console: the card detail screen gets a confirm-gated Void action for active cards, composing the existing void capability. **Added:** *Gift-card void* — The gift-card detail screen shows a confirm-gated "Void card" action for active cards. Voiding goes through a confirmation step and is content-negotiated like the rest of the console (a bearer-token client gets the voided card as JSON; already-redeemed cards are refused). **Changed:** *Payment-readiness banner on the admin landing* — The admin landing shows a "Payments aren't live yet — customers can't check out. See Integrations" banner whenever Stripe isn't enabled, gated on live payment readiness rather than on whether the setup wizard was marked complete. The existing not-set-up identity banner is unchanged; both can appear together.
14
+
11
15
  - v0.2.17 (2026-05-29) — **Manage your passkeys and edit your profile from your account.** Signed-in customers can now manage their own passkeys and edit their profile, instead of only being able to enrol a passkey at registration. A new account screen lists each enrolled passkey (device transport + when it was added) with an add-another flow and a confirm-gated revoke; revocation is scoped to the signed-in account and refuses to remove your last sign-in method when no other (OAuth) method is linked, so you can't lock yourself out. A separate profile screen edits the display name. **Added:** *Passkey management* — `/account/passkeys` lists your enrolled passkeys, lets you register an additional one, and revoke one you no longer use (behind a confirmation step). Revocation only ever affects your own credentials, and the last remaining sign-in method is protected against removal. · *Profile editing* — `/account/profile` edits your display name with a confirmation notice. Account links for both screens were added to the account dashboard.
12
16
 
13
17
  - v0.2.16 (2026-05-29) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* — `/admin/tax-rates` — pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* — `/admin/shipping` — create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* — `/admin/discounts` — create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).
package/README.md CHANGED
@@ -87,7 +87,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
87
87
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
88
88
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
89
89
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
90
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), and open a card to see its full credit / debit / expire ledger; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
90
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
91
91
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
92
92
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
93
93
 
package/lib/admin.js CHANGED
@@ -3062,6 +3062,7 @@ function mount(router, deps) {
3062
3062
  // once and clear the cookie. A subsequent reload reveals nothing —
3063
3063
  // the code never lands in the URL / history / access log.
3064
3064
  var issuedCode = _takeGiftCardReveal(req, res, req.params.id);
3065
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3065
3066
  var history = [];
3066
3067
  if (giftCardLedger) {
3067
3068
  try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
@@ -3070,9 +3071,66 @@ function mount(router, deps) {
3070
3071
  _sendHtml(res, 200, renderAdminGiftCard({
3071
3072
  shop_name: deps.shop_name, nav_available: navAvailable, card: card,
3072
3073
  ledger: history, issued_code: issuedCode || undefined,
3074
+ voided: !!(url && url.searchParams.get("voided")),
3075
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for this card." : null,
3073
3076
  }));
3074
3077
  },
3075
3078
  ));
3079
+
3080
+ // Browser confirmation interstitial for void — voiding revokes a
3081
+ // money instrument (the remaining balance can no longer be redeemed),
3082
+ // so the console confirms before the real POST. Bearer clients skip
3083
+ // this and POST .../void directly (mirrors the webhook delete pattern).
3084
+ router.post("/admin/gift-cards/:id/void/confirm", _pageOrApi(false,
3085
+ R(async function (_req, res) {
3086
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/gift-cards/:id/void directly for the JSON API");
3087
+ }),
3088
+ async function (req, res) {
3089
+ var card = await giftcards.getById(req.params.id);
3090
+ if (!card) return _redirect(res, "/admin/gift-cards?err=1");
3091
+ if (card.status !== "active") return _redirect(res, "/admin/gift-cards/" + encodeURIComponent(req.params.id) + "?err=1");
3092
+ var cur = String(card.currency || "").toUpperCase();
3093
+ _sendHtml(res, 200, renderAdminConfirm({
3094
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "giftcards",
3095
+ heading: "Void this gift card?",
3096
+ consequence: "Voiding is permanent — the remaining balance can no longer be redeemed at checkout.",
3097
+ detail: "Card ••••" + card.code_hint + " with " + pricing.format(card.balance_minor, cur) + " remaining will be voided.",
3098
+ action: "/admin/gift-cards/" + _htmlEscape(req.params.id) + "/void",
3099
+ confirm_label: "Void card",
3100
+ cancel_href: "/admin/gift-cards/" + encodeURIComponent(req.params.id),
3101
+ }));
3102
+ },
3103
+ ));
3104
+
3105
+ // Void a card. Bearer → JSON (the voided row); browser form → void,
3106
+ // then PRG back to the card's detail page. `void` is idempotent on an
3107
+ // already-voided card and refuses a fully-redeemed one; both surfaces
3108
+ // translate that to a 4xx / err flag rather than a 500.
3109
+ router.post("/admin/gift-cards/:id/void", _pageOrApi(false,
3110
+ W("gift_card.void", async function (req, res) {
3111
+ var card;
3112
+ try { card = await giftcards.void(req.params.id, { reason: (req.body && req.body.reason) || undefined }); }
3113
+ catch (e) {
3114
+ if (e && e.code === "GIFTCARD_ALREADY_REDEEMED") return _problem(res, 409, "gift-card-redeemed", e.message);
3115
+ throw e;
3116
+ }
3117
+ if (!card) return _problem(res, 404, "gift-card-not-found");
3118
+ _json(res, 200, card);
3119
+ return { id: card.id };
3120
+ }),
3121
+ async function (req, res) {
3122
+ var card = null;
3123
+ try { card = await giftcards.void(req.params.id, { reason: (req.body && req.body.reason) || undefined }); }
3124
+ catch (e) {
3125
+ if (e && e.code === "GIFTCARD_ALREADY_REDEEMED") return _redirect(res, "/admin/gift-cards/" + encodeURIComponent(req.params.id) + "?err=1");
3126
+ if (!(e instanceof TypeError)) throw e;
3127
+ return _redirect(res, "/admin/gift-cards?err=1");
3128
+ }
3129
+ if (!card) return _redirect(res, "/admin/gift-cards?err=1");
3130
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift_card.void", outcome: "success", metadata: { id: req.params.id } });
3131
+ _redirect(res, "/admin/gift-cards/" + encodeURIComponent(req.params.id) + "?voided=1");
3132
+ },
3133
+ ));
3076
3134
  }
3077
3135
 
3078
3136
  // Issue a card from operator input, recording the opening balance as
@@ -3743,6 +3801,11 @@ function mount(router, deps) {
3743
3801
  _sendHtml(res, 200, renderAdminLanding({
3744
3802
  shop_name: deps.shop_name,
3745
3803
  setup_complete: await _setupComplete(),
3804
+ // Live payment status from the entry-point integration map. The
3805
+ // landing flags "payments aren't live" until Stripe is "enabled",
3806
+ // INDEPENDENT of the identity-only setup step — finishing the setup
3807
+ // wizard doesn't make checkout work if the Stripe secrets are unset.
3808
+ payments_live: ((deps.integrations || {}).stripe === "enabled"),
3746
3809
  nav_available: navAvailable,
3747
3810
  }));
3748
3811
  });
@@ -4135,8 +4198,14 @@ function renderAdminLanding(opts) {
4135
4198
  var setupBanner = opts.setup_complete
4136
4199
  ? ""
4137
4200
  : "<div class=\"banner banner--warn\">Your shop isn't set up yet. <a href=\"/admin/setup\">Finish setup &rarr;</a></div>";
4201
+ // Payments are gated on Stripe env secrets, not the identity-only setup
4202
+ // step — so this banner shows independently of `setup_complete`. Until
4203
+ // Stripe is live, checkout 404/503s even for a "finished" shop.
4204
+ var paymentsBanner = opts.payments_live
4205
+ ? ""
4206
+ : "<div class=\"banner banner--warn\">Payments aren't live yet — customers can't check out. <a href=\"/admin/integrations\">See Integrations &rarr;</a></div>";
4138
4207
  var body =
4139
- "<section>" + setupBanner +
4208
+ "<section>" + setupBanner + paymentsBanner +
4140
4209
  "<h2>Admin</h2>" +
4141
4210
  "<div class=\"nav-cards\">" +
4142
4211
  "<a class=\"nav-card\" href=\"/admin/setup\"><h3>Setup wizard</h3><p>Shop identity, currency, and contact details.</p></a>" +
@@ -5308,6 +5377,8 @@ function renderAdminGiftCard(opts) {
5308
5377
  ? "<div class=\"banner banner--ok\">Copy the code now — it is shown once and cannot be retrieved again: " +
5309
5378
  "<code class=\"order-id\">" + _htmlEscape(opts.issued_code) + "</code></div>"
5310
5379
  : "";
5380
+ var voidedBanner = opts.voided ? "<div class=\"banner banner--ok\">Gift card voided. Its remaining balance can no longer be redeemed.</div>" : "";
5381
+ var noticeBanner = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
5311
5382
 
5312
5383
  var statusCls = gc.status === "active" ? "paid" : (gc.status === "redeemed" ? "pending" : "cancelled");
5313
5384
  var summary =
@@ -5338,9 +5409,16 @@ function renderAdminGiftCard(opts) {
5338
5409
  ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Type</th><th scope=\"col\" class=\"num\">Amount</th><th scope=\"col\" class=\"num\">Balance after</th><th scope=\"col\">Detail</th><th scope=\"col\">When</th></tr></thead><tbody>" + ledgerRows + "</tbody></table></div>"
5339
5410
  : "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
5340
5411
 
5341
- var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
5412
+ // Void is offered only while the card is active a redeemed card has
5413
+ // no balance to revoke, a voided card is already revoked. The confirm
5414
+ // interstitial guards the destructive POST.
5415
+ var voidAction = gc.status === "active"
5416
+ ? "<form method=\"post\" action=\"/admin/gift-cards/" + _htmlEscape(gc.id) + "/void/confirm\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Void card</button></form>"
5417
+ : "";
5418
+
5419
+ var body = "<section><h2>Gift card</h2>" + issuedBanner + voidedBanner + noticeBanner + summary +
5342
5420
  "<h3 class=\"subhead subhead--sp-lg\">Ledger</h3>" + ledgerTable +
5343
- "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
5421
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a>" + voidAction + "</div></section>";
5344
5422
  return _renderAdminShell(opts.shop_name, "Gift card", body, "giftcards", opts.nav_available);
5345
5423
  }
5346
5424
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.17",
2
+ "version": "0.2.19",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
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": {