@blamejs/blamejs-shop 0.1.16 → 0.1.17

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.17 (2026-05-25) — **Admin console — an inventory screen.** Inventory joins the admin console. `/admin/inventory` lists stock per SKU — on hand, held, and available — with a low-stock filter, restocks a SKU, sets or clears its per-SKU low-stock threshold, and tracks a new SKU, all from a signed-in browser. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Inventory management screen* — `/admin/inventory` renders the inventory table (SKU, on-hand, held, available) with a low-stock filter (`?low=1`) that surfaces SKUs at or below their threshold, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each row's form restocks (add quantity) and sets the low-stock threshold (blank clears it) in one submit, and a form below tracks a new SKU. The create / restock JSON API is unchanged for tooling; the browser forms post and redirect (PRG), and a bad SKU is a no-op notice rather than a 500. · *catalog.inventory.list* — `catalog.inventory.list({ limit, low_only })` returns inventory rows (SKU ascending), optionally only those at or below their configured low-stock threshold — the read backing the console list and the JSON endpoint. **Changed:** *Console nav gains Inventory* — The signed-in admin nav now includes Inventory alongside Products, Orders, Returns, and Reviews. The `/admin/inventory` list and the create / restock endpoints content-negotiate like the other screens: a bearer-token client gets the JSON API, a signed-in browser gets HTML. A request without the bearer token on these paths now returns the sign-in form on a GET and redirects on a write, matching the other console screens.
12
+
11
13
  - v0.1.16 (2026-05-25) — **PayPal express checkout — the on-page button.** PayPal checkout is now usable from the storefront. When PayPal is configured, the checkout page shows a native PayPal button (distinct from PayPal-through-Stripe): it opens a PayPal order for the current cart, the buyer approves in the PayPal popup, and the order is captured and marked paid. A verified PayPal webhook is the asynchronous backstop. This completes the native PayPal integration on top of the adapter and checkout orchestration shipped in the previous two releases. Card / Stripe checkout is unchanged. **Added:** *PayPal button + create/capture routes on the storefront* — The checkout page renders a PayPal button when `PAYPAL_CLIENT_ID` is configured. Its create step posts to `POST /checkout/paypal/create` (prices the cart, opens a PayPal order, persists the local order pending) and its approve step posts to `POST /checkout/paypal/capture` (captures and advances the order to paid, then redirects to the order page). Both validate input and never 500 on a missing cart or id. The buttons collect the same shipping fields as the card form. · *PayPal webhook endpoint* — `POST /api/webhooks/paypal` is the asynchronous backstop for captures completed or refunded out of band. The container verifies each event server-to-server through PayPal's verify-webhook-signature API (no edge HMAC pre-check, unlike Stripe), then advances the order; re-deliveries are idempotent. Point a PayPal webhook at `/api/webhooks/paypal`. **Changed:** *PayPal listed as configurable; CSP note* — The integrations status page and README document PayPal as a first-class checkout option once configured. As with the Stripe pay page's `js.stripe.com`, operators must allow `www.paypal.com` in their Content-Security-Policy `script-src` / `frame-src` for the PayPal SDK and approval popup.
12
14
 
13
15
  - v0.1.15 (2026-05-25) — **PayPal checkout orchestration.** Checkout can now run a PayPal order end to end, building on the adapter from the previous release. The orchestrator prices the cart, opens a PayPal order and persists the local order as pending, captures it after the buyer approves and advances the order to paid, and has a webhook backstop for captures completed or refunded out of band. Wired in when a PayPal app's credentials are present, and surfaced on the integrations status page. The storefront button and routes that drive this from the pay page come next; card / Stripe checkout is unchanged. **Added:** *checkout PayPal methods* — With a PayPal adapter wired (`paypal` dep), checkout gains three methods. `createPaypalOrder({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key, return_url?, cancel_url? })` prices the cart, opens a PayPal Orders-v2 order, persists the local order in `pending` with the PayPal order id linked, and marks the cart converted. `capturePaypalOrder(paypalOrderId)` captures the approved order and advances the local order to `paid` (idempotent — a retry or a webhook that beat it won't double-transition). `handlePaypalEvent({ headers, rawBody })` is the asynchronous backstop: it verifies the event through PayPal, then maps `PAYMENT.CAPTURE.COMPLETED` → paid and `PAYMENT.CAPTURE.REFUNDED` → refunded, idempotent across re-deliveries. · *PayPal wired from configuration* — When `PAYPAL_CLIENT_ID` + `PAYPAL_SECRET` are set (with `PAYPAL_ENV` and `PAYPAL_WEBHOOK_ID`), the server builds the PayPal adapter and passes it to checkout; the integrations status page lists PayPal as action-needed once card checkout is also live. Off until configured; the existing checkout flow is untouched.
package/README.md CHANGED
@@ -72,7 +72,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
72
72
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
73
73
  | **`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. |
74
74
  | **`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. |
75
- | **`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; **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; **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. The Returns and Reviews 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. |
75
+ | **`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; **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; **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. The Returns and Reviews 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. |
76
76
  | **`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. |
77
77
  | **`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. |
78
78
 
package/lib/admin.js CHANGED
@@ -400,22 +400,90 @@ function mount(router, deps) {
400
400
 
401
401
  // ---- inventory ------------------------------------------------------
402
402
 
403
- router.post("/admin/inventory", W("inventory.create", async function (req, res) {
404
- var body = req.body || {};
405
- if (!body.sku) throw new TypeError("admin.inventory.create: body.sku required");
406
- var inv = await catalog.inventory.create(body.sku, body);
407
- _json(res, 201, inv);
408
- return Object.assign({ id: body.sku }, inv);
409
- }));
403
+ // Inventory list JSON for the bearer token, HTML console for a signed-in
404
+ // browser. `?low=1` filters to SKUs at/below their low-stock threshold.
405
+ router.get("/admin/inventory", _pageOrApi(true,
406
+ R(async function (req, res) {
407
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
408
+ var page = await catalog.inventory.list({ low_only: !!(url && url.searchParams.get("low")), limit: 500 });
409
+ _json(res, 200, page);
410
+ }),
411
+ async function (req, res) {
412
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
413
+ var low = !!(url && url.searchParams.get("low"));
414
+ var page = await catalog.inventory.list({ low_only: low, limit: 500 });
415
+ _sendHtml(res, 200, renderAdminInventory({
416
+ shop_name: deps.shop_name, nav_available: navAvailable,
417
+ inventory: page.rows || [], low: low,
418
+ notice: url && url.searchParams.get("err") ? "That SKU wasn't found — nothing was changed." : null,
419
+ updated: !!(url && url.searchParams.get("updated")),
420
+ created: !!(url && url.searchParams.get("created")),
421
+ }));
422
+ },
423
+ ));
410
424
 
411
- router.post("/admin/inventory/:sku/restock", W("inventory.restock", async function (req, res) {
412
- var qty = parseInt((req.body || {}).qty, 10);
413
- if (!Number.isFinite(qty)) throw new TypeError("admin.inventory.restock: body.qty required (integer)");
414
- var inv = await catalog.inventory.restock(req.params.sku, qty);
415
- if (!inv) return _problem(res, 404, "inventory-not-found");
416
- _json(res, 200, inv);
417
- return Object.assign({ id: req.params.sku }, inv);
418
- }));
425
+ router.post("/admin/inventory", _pageOrApi(false,
426
+ W("inventory.create", async function (req, res) {
427
+ var body = req.body || {};
428
+ if (!body.sku) throw new TypeError("admin.inventory.create: body.sku required");
429
+ var inv = await catalog.inventory.create(body.sku, body);
430
+ _json(res, 201, inv);
431
+ return Object.assign({ id: body.sku }, inv);
432
+ }),
433
+ async function (req, res) {
434
+ var body = req.body || {};
435
+ try {
436
+ if (!body.sku) throw new TypeError("sku required");
437
+ await catalog.inventory.create(body.sku, { stock_on_hand: parseInt(body.stock_on_hand, 10) || 0 });
438
+ } catch (e) {
439
+ if (e instanceof TypeError || /exists|duplicate|UNIQUE/i.test(e.message || "")) {
440
+ var page = await catalog.inventory.list({ limit: 500 });
441
+ return _sendHtml(res, 400, renderAdminInventory({
442
+ shop_name: deps.shop_name, nav_available: navAvailable, inventory: page.rows || [],
443
+ notice: (e && e.message) || "Couldn't create that SKU.",
444
+ }));
445
+ }
446
+ throw e;
447
+ }
448
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.create", outcome: "success", metadata: { sku: body.sku } });
449
+ _redirect(res, "/admin/inventory?created=1");
450
+ },
451
+ ));
452
+
453
+ router.post("/admin/inventory/:sku/restock", _pageOrApi(false,
454
+ W("inventory.restock", async function (req, res) {
455
+ var qty = parseInt((req.body || {}).qty, 10);
456
+ if (!Number.isFinite(qty)) throw new TypeError("admin.inventory.restock: body.qty required (integer)");
457
+ var inv = await catalog.inventory.restock(req.params.sku, qty);
458
+ if (!inv) return _problem(res, 404, "inventory-not-found");
459
+ _json(res, 200, inv);
460
+ return Object.assign({ id: req.params.sku }, inv);
461
+ }),
462
+ async function (req, res) {
463
+ // Browser row form: restock by qty (when > 0) and/or set the low-stock
464
+ // threshold (when the field is non-empty; blank clears it). A bad sku is
465
+ // a no-op notice, never a 500.
466
+ var body = req.body || {};
467
+ var sku = req.params.sku;
468
+ var changed = false;
469
+ try {
470
+ var qty = parseInt(body.qty, 10);
471
+ if (Number.isFinite(qty) && qty > 0) { if (await catalog.inventory.restock(sku, qty)) changed = true; }
472
+ if (Object.prototype.hasOwnProperty.call(body, "threshold")) {
473
+ var raw = String(body.threshold).trim();
474
+ var threshold = raw === "" ? null : parseInt(raw, 10);
475
+ if (threshold === null || (Number.isInteger(threshold) && threshold >= 0)) {
476
+ if (await catalog.inventory.setThreshold(sku, threshold)) changed = true;
477
+ }
478
+ }
479
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
480
+ // restock / setThreshold return null for an unknown SKU — don't report
481
+ // success on a stale/tampered form to a non-existent SKU.
482
+ if (!changed) return _redirect(res, "/admin/inventory?err=1");
483
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.restock", outcome: "success", metadata: { sku: sku } });
484
+ _redirect(res, "/admin/inventory?updated=1");
485
+ },
486
+ ));
419
487
 
420
488
  // Per-SKU low-stock threshold. Body `{ threshold }` — null clears.
421
489
  router.patch("/admin/inventory/:sku/threshold", W("inventory.set_threshold", async function (req, res) {
@@ -1497,6 +1565,9 @@ var DASHBOARD_LAYOUT =
1497
1565
  " .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
1498
1566
  " .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
1499
1567
  " .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1568
+ " .inv-row-form { display:flex; gap:.4rem; align-items:center; }\n" +
1569
+ " .inv-row-form input { padding:.4rem .5rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1570
+ " tr.row--low td { background:#fff8e1; }\n" +
1500
1571
  " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1501
1572
  " .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
1502
1573
  " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
@@ -1677,6 +1748,7 @@ var ADMIN_NAV_ITEMS = [
1677
1748
  { key: "home", href: "/admin", label: "Home" },
1678
1749
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
1679
1750
  { key: "products", href: "/admin/products", label: "Products" },
1751
+ { key: "inventory", href: "/admin/inventory", label: "Inventory" },
1680
1752
  { key: "orders", href: "/admin/orders", label: "Orders" },
1681
1753
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1682
1754
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
@@ -2178,6 +2250,55 @@ function renderAdminReviews(opts) {
2178
2250
  return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
2179
2251
  }
2180
2252
 
2253
+ function renderAdminInventory(opts) {
2254
+ opts = opts || {};
2255
+ var rows = opts.inventory || [];
2256
+ var created = opts.created ? "<div class=\"banner banner--ok\">SKU created.</div>" : "";
2257
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Inventory updated.</div>" : "";
2258
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2259
+
2260
+ var chips = "<div class=\"order-filters\">" +
2261
+ "<a class=\"chip" + (opts.low ? "" : " chip--on") + "\" href=\"/admin/inventory\">All</a>" +
2262
+ "<a class=\"chip" + (opts.low ? " chip--on" : "") + "\" href=\"/admin/inventory?low=1\">Low stock</a>" +
2263
+ "</div>";
2264
+
2265
+ var body = rows.map(function (r) {
2266
+ var available = (r.stock_on_hand || 0) - (r.stock_held || 0);
2267
+ var th = r.low_stock_threshold;
2268
+ var isLow = th != null && available <= th;
2269
+ var thVal = th == null ? "" : String(th);
2270
+ return "<tr" + (isLow ? " class=\"row--low\"" : "") + ">" +
2271
+ "<td><code class=\"order-id\">" + _htmlEscape(r.sku) + "</code>" + (isLow ? " <span class=\"status-pill pending\">low</span>" : "") + "</td>" +
2272
+ "<td class=\"num\">" + _htmlEscape(String(r.stock_on_hand)) + "</td>" +
2273
+ "<td class=\"num\">" + _htmlEscape(String(r.stock_held)) + "</td>" +
2274
+ "<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
2275
+ "<td>" +
2276
+ "<form method=\"post\" action=\"/admin/inventory/" + _htmlEscape(r.sku) + "/restock\" class=\"inv-row-form\">" +
2277
+ "<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" style=\"width:6rem;\">" +
2278
+ "<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" style=\"width:6rem;\">" +
2279
+ "<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
2280
+ "</form>" +
2281
+ "</td></tr>";
2282
+ }).join("");
2283
+
2284
+ var table = rows.length
2285
+ ? "<div class=\"panel\"><table><thead><tr><th>SKU</th><th class=\"num\">On hand</th><th class=\"num\">Held</th><th class=\"num\">Available</th><th>Restock / threshold</th></tr></thead><tbody>" + body + "</tbody></table></div>"
2286
+ : "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
2287
+
2288
+ var createForm =
2289
+ "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2290
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Track a new SKU</h3>" +
2291
+ "<form method=\"post\" action=\"/admin/inventory\">" +
2292
+ _setupField("SKU", "sku", "", "text", "Must match a variant SKU.", " maxlength=\"128\" required") +
2293
+ _setupField("Starting stock on hand", "stock_on_hand", "0", "number", "", " min=\"0\"") +
2294
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Track SKU</button></div>" +
2295
+ "</form>" +
2296
+ "</div>";
2297
+
2298
+ var bodyHtml = "<section><h2>Inventory</h2>" + created + updated + notice + chips + table + createForm + "</section>";
2299
+ return _renderAdminShell(opts.shop_name, "Inventory", bodyHtml, "inventory", opts.nav_available);
2300
+ }
2301
+
2181
2302
  module.exports = {
2182
2303
  mount: mount,
2183
2304
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
@@ -2187,6 +2308,7 @@ module.exports = {
2187
2308
  renderAdminSetup: renderAdminSetup,
2188
2309
  renderAdminIntegrations: renderAdminIntegrations,
2189
2310
  renderAdminProducts: renderAdminProducts,
2311
+ renderAdminInventory: renderAdminInventory,
2190
2312
  renderAdminOrders: renderAdminOrders,
2191
2313
  renderAdminOrder: renderAdminOrder,
2192
2314
  renderAdminReturns: renderAdminReturns,
package/lib/catalog.js CHANGED
@@ -629,6 +629,22 @@ function _inventoryModule(query, opts) {
629
629
  return r.rows[0] || null;
630
630
  },
631
631
 
632
+ // Operator-facing inventory list (sku ASC), optionally only the SKUs at
633
+ // or below their configured low-stock threshold. Capped + uncursored —
634
+ // it backs the admin inventory console, not a hot path.
635
+ list: async function (listOpts) {
636
+ listOpts = listOpts || {};
637
+ var limit = listOpts.limit == null ? 200 : listOpts.limit;
638
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
639
+ throw new TypeError("catalog.inventory.list: limit must be 1..1000");
640
+ }
641
+ var sql = listOpts.low_only
642
+ ? "SELECT * FROM inventory WHERE low_stock_threshold IS NOT NULL AND " +
643
+ "(stock_on_hand - stock_held) <= low_stock_threshold ORDER BY sku ASC LIMIT ?1"
644
+ : "SELECT * FROM inventory ORDER BY sku ASC LIMIT ?1";
645
+ return { rows: (await query(sql, [limit])).rows };
646
+ },
647
+
632
648
  // Hot-path decrement happens via the Worker's InventoryLock
633
649
  // Durable Object — this primitive is for admin restock / release
634
650
  // operations that don't need the DO serialization. Concurrent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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": {