@blamejs/blamejs-shop 0.0.124 → 0.0.127

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.0.x
10
10
 
11
+ - v0.0.127 (2026-05-24) — **Self-serve returns — customers request RMAs on their orders, operators approve and refund.** Signed-in customers can request a return against one of their own orders — pick the items and a reason at the order, then track status at /account/returns. Operators work the queue at /admin/returns: approve (with a refund amount), mark received, refund, or reject with a reason, following the pending → approved → received → refunded lifecycle. The customer request route loads the order and confirms it belongs to the signed-in customer before showing it, and builds the return lines from the order's own records, so a foreign or guessed order id returns 404 and a client can't return items it never bought. **Added:** *Customer return requests + status* — `/account/orders/:order_id/return` shows the order's items with a reason picker; `/account/returns` lists the customer's RMAs with status (pending / approved / received / refunded / rejected) and any rejection reason. The account dashboard links to it. Empty selection or a bad reason re-renders the form with the message. · *Operator return queue* — `GET /admin/returns?status=pending` lists the queue across all orders; `GET /admin/returns/:id` reads one; `POST /admin/returns/:id/approve` (with refund amount), `/received`, `/refund`, and `/reject` (with reason) walk the lifecycle. Bearer-token-gated; an illegal transition is a 409 and a bad id a 404, never a 500. · *`returns.listByStatus(status, opts)`* — Lists return authorizations across all orders by status, newest first, with the same opaque cursor as `listForCustomer`. Backs the operator queue.
12
+
13
+ - v0.0.126 (2026-05-24) — **Address book — saved shipping and billing addresses on the account page.** Signed-in customers can now keep a book of addresses at /account/addresses: add, edit, set a default shipping or billing address, and remove. Addresses are per-customer; every action that targets a specific address first confirms it belongs to the signed-in customer, so a guessed id can't read or change someone else's address. **Added:** *Address book at `/account/addresses`* — List, add, and edit saved addresses (recipient, company, two street lines, city, region, postal code, ISO country, phone). The account dashboard links to it. A blank product catalog isn't required — this is account surface only. · *Default shipping / billing* — Mark one address as the default shipping and one as the default billing; promoting a new default clears the previous one. Surfaced as badges on each card. · *Ownership-checked mutations* — Edit, update, set-default, and remove all resolve the address by id and verify it belongs to the signed-in customer before acting — a foreign or guessed id returns 404, never another customer's data. Add/edit re-render the form with the validation message on bad input.
14
+
11
15
  - v0.0.124 (2026-05-24) — **Save for later — move cart items into a holding list and back.** Each cart line now has a "Save for later" control that moves the item out of the cart into a per-customer holding list without losing it. Saved items live on a new account page where they can be moved back to the cart or removed. Moving an item back reprices it to the current catalog price and checks stock first, so a saved item that sold out (and isn't backorderable) can't silently re-enter the cart. Login-required, since the list is scoped to one customer. **Added:** *Save-for-later control on cart lines* — Each editable cart line gets a Save-for-later control. `POST /cart/lines/:line_id/save` moves the line out of the cart into the customer's saved list (`moveFromCart`). Login-gated — a signed-out shopper is redirected to sign in. · *`/account/saved` — the holding list* — A new account page lists saved items with a thumbnail, the saved price for reference, and Move-to-cart / Remove controls. Items whose product was archived render as "no longer available" rather than breaking the list. The account dashboard links to it (alongside Wishlist). · *Move back to cart, repriced + stock-checked* — `POST /saved/:save_id/move-to-cart` returns the item to the session cart at the current catalog price (not the stale snapshot) and refuses if the SKU is out of stock and not backorderable. `POST /saved/:save_id/remove` drops a saved row.
12
16
 
13
17
  - v0.0.123 (2026-05-24) — **Wishlist — save products to your account, with social-proof counts on the product page.** Signed-in customers can now save products to a wishlist from the product page. A "N shoppers saved this" count surfaces social proof, and saved items live on a new account page where they can be removed or reopened. The save control and count render identically on the edge and container paths; the toggle is idempotent (saving twice is a no-op, toggling again removes) and login-gated, since a wishlist is scoped to one customer. **Added:** *Save to wishlist on the product page* — The PDP renders a "Save to wishlist" control and, once any customer has saved it, a "N shoppers saved this" social-proof count. Both render server-side on the edge and container paths. The count is public; the save action requires sign-in. · *`/account/wishlist` — saved items* — A new account page lists the customer's saved products with a thumbnail, a link back to the product, and a Remove control. Entries whose product was archived render as "no longer available" rather than breaking the list (wishlist rows are orphan-tolerant by design). The account dashboard links to it. · *`POST /wishlist/toggle`* — Login-required endpoint that saves the product if it isn't saved and removes it if it is. Idempotent (`INSERT OR IGNORE`). Redirects back to the product by resolving its canonical slug from the product id, or to a safe same-origin `return_to` (the account page's Remove uses it) — a forged or off-site redirect target is rejected.
package/README.md CHANGED
@@ -66,9 +66,11 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
67
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
68
68
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
69
+ | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
70
+ | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
69
71
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
70
72
  | **`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. |
71
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
73
+ | **`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. |
72
74
  | **`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. |
73
75
  | **`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. |
74
76
 
@@ -86,6 +88,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
86
88
  - `migrations-d1/0011_reviews.sql` — operator-moderated product reviews (hash-only author identity)
87
89
  - `migrations-d1/0012_wishlist.sql` — per-customer saved products (unique customer + product + variant)
88
90
  - `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
91
+ - `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
92
+ - `migrations-d1/0023_returns.sql` — return authorizations + lines (RMA lifecycle FSM)
89
93
 
90
94
  ### Demo seed
91
95
 
package/lib/admin.js CHANGED
@@ -163,6 +163,7 @@ function mount(router, deps) {
163
163
  var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
164
164
  var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
165
165
  var reviews = deps.reviews || null; // moderation endpoints disabled when absent
166
+ var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
166
167
 
167
168
  try { _b().audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
168
169
 
@@ -580,6 +581,118 @@ function mount(router, deps) {
580
581
  }));
581
582
  }
582
583
 
584
+ // ---- returns (moderation) -------------------------------------------
585
+
586
+ // Operator-side RMA moderation. The queue lists return
587
+ // authorizations across all orders in one status (defaults to
588
+ // `pending`); approve / received / refund / reject walk the same FSM
589
+ // the customer-facing request path leaves in `pending`. A bad state
590
+ // transition (e.g. refund-from-pending) and a malformed :id both
591
+ // surface as client errors (4xx), never a 500. Endpoints are omitted
592
+ // entirely when no returns primitive is wired.
593
+ if (returns) {
594
+ function _returnsClientError(e) {
595
+ // A transition refused by the FSM or a not-found row is the
596
+ // caller's problem, not the server's. `_currentStatus` raises a
597
+ // not-found TypeError; `_assertTransition` raises an Error tagged
598
+ // RMA_TRANSITION_REFUSED. Map both to 4xx. (Bad-shape input is a
599
+ // plain TypeError, which the wrapper already maps to 400.)
600
+ if (!e) return null;
601
+ if (e.code === "RMA_NOT_FOUND") return { status: 404, slug: "return-not-found" };
602
+ if (e.code === "RMA_TRANSITION_REFUSED") return { status: 409, slug: "return-transition-refused" };
603
+ return null;
604
+ }
605
+
606
+ router.get("/admin/returns", R(async function (req, res) {
607
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
608
+ var status = (url && url.searchParams.get("status")) || "pending";
609
+ var cursor = url && url.searchParams.get("cursor");
610
+ var limitS = url && url.searchParams.get("limit");
611
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
612
+ var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
613
+ _json(res, 200, page);
614
+ }));
615
+
616
+ router.get("/admin/returns/:id", R(async function (req, res) {
617
+ var rma;
618
+ try {
619
+ rma = await returns.get(req.params.id);
620
+ } catch (e) {
621
+ // A non-UUID :id raises a guardUuid TypeError — surface it as a
622
+ // 404 (the route is a defensive request-shape reader, never a
623
+ // 500). Re-raise anything that isn't the bad-id shape so the
624
+ // wrapper's generic handling applies.
625
+ if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
626
+ throw e;
627
+ }
628
+ if (!rma) return _problem(res, 404, "return-not-found");
629
+ _json(res, 200, rma);
630
+ }));
631
+
632
+ router.post("/admin/returns/:id/approve", W("return.approve", async function (req, res) {
633
+ var body = req.body || {};
634
+ var rma;
635
+ try {
636
+ rma = await returns.approve(req.params.id, {
637
+ refund_amount_minor: body.refund_amount_minor,
638
+ refund_currency: body.refund_currency,
639
+ operator_notes: body.operator_notes,
640
+ });
641
+ } catch (e) {
642
+ var ce = _returnsClientError(e);
643
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
644
+ throw e;
645
+ }
646
+ _json(res, 200, rma);
647
+ return rma;
648
+ }));
649
+
650
+ router.post("/admin/returns/:id/received", W("return.received", async function (req, res) {
651
+ var body = req.body || {};
652
+ var rma;
653
+ try {
654
+ rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
655
+ } catch (e) {
656
+ var ce = _returnsClientError(e);
657
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
658
+ throw e;
659
+ }
660
+ _json(res, 200, rma);
661
+ return rma;
662
+ }));
663
+
664
+ router.post("/admin/returns/:id/refund", W("return.refund", async function (req, res) {
665
+ var body = req.body || {};
666
+ var rma;
667
+ try {
668
+ rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
669
+ } catch (e) {
670
+ var ce = _returnsClientError(e);
671
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
672
+ throw e;
673
+ }
674
+ _json(res, 200, rma);
675
+ return rma;
676
+ }));
677
+
678
+ router.post("/admin/returns/:id/reject", W("return.reject", async function (req, res) {
679
+ var body = req.body || {};
680
+ var rma;
681
+ try {
682
+ rma = await returns.reject(req.params.id, {
683
+ rejected_reason: body.rejected_reason,
684
+ operator_notes: body.operator_notes,
685
+ });
686
+ } catch (e) {
687
+ var ce = _returnsClientError(e);
688
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
689
+ throw e;
690
+ }
691
+ _json(res, 200, rma);
692
+ return rma;
693
+ }));
694
+ }
695
+
583
696
  // ---- config ---------------------------------------------------------
584
697
 
585
698
  var config = deps.config || null;
package/lib/returns.js CHANGED
@@ -516,6 +516,57 @@ function create(opts) {
516
516
  return { rows: rows, next_cursor: next };
517
517
  },
518
518
 
519
+ listByStatus: async function (status, listOpts) {
520
+ var statusFilter = _status(status);
521
+ listOpts = listOpts || {};
522
+ var limit = listOpts.limit == null ? DEFAULT_LIST_LIMIT : listOpts.limit;
523
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
524
+ throw new TypeError("returns.listByStatus: limit must be 1..." + MAX_LIST_LIMIT);
525
+ }
526
+ var cursorVals = null;
527
+ if (listOpts.cursor != null) {
528
+ if (typeof listOpts.cursor !== "string") {
529
+ throw new TypeError("returns.listByStatus: cursor must be an opaque string or null");
530
+ }
531
+ try {
532
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
533
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(RMA_ORDER_KEY)) {
534
+ throw new TypeError("returns.listByStatus: cursor orderKey mismatch");
535
+ }
536
+ cursorVals = state.vals;
537
+ } catch (e) {
538
+ if (e instanceof TypeError) throw e;
539
+ throw new TypeError("returns.listByStatus: cursor — " + (e && e.message || "malformed"));
540
+ }
541
+ }
542
+
543
+ var sql, params;
544
+ if (cursorVals) {
545
+ sql = "SELECT * FROM return_authorizations WHERE status = ?1 AND " +
546
+ "(created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
547
+ "ORDER BY created_at DESC, id DESC LIMIT ?4";
548
+ params = [statusFilter, cursorVals[0], cursorVals[1], limit];
549
+ } else {
550
+ sql = "SELECT * FROM return_authorizations WHERE status = ?1 " +
551
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
552
+ params = [statusFilter, limit];
553
+ }
554
+ var rows = (await query(sql, params)).rows;
555
+ for (var i = 0; i < rows.length; i += 1) {
556
+ await _hydrate(rows[i]);
557
+ }
558
+ var last = rows[rows.length - 1];
559
+ var next = null;
560
+ if (last && rows.length === limit) {
561
+ next = _b().pagination.encodeCursor({
562
+ orderKey: RMA_ORDER_KEY,
563
+ vals: [last.created_at, last.id],
564
+ forward: true,
565
+ }, cursorSecret);
566
+ }
567
+ return { rows: rows, next_cursor: next };
568
+ },
569
+
519
570
  summaryForOperator: async function (input) {
520
571
  input = input || {};
521
572
  var from = _epochOrNull(input.from, "from");