@blamejs/blamejs-shop 0.1.16 → 0.1.18

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.1.x
10
10
 
11
+ - v0.1.18 (2026-05-25) — **Admin console — a subscription-plans screen.** Subscription plans join the admin console. `/admin/subscription-plans` lists the recurring-offer catalog — price, interval, trial, and Stripe price id — with an active / archived filter, creates a plan from a signed-in browser, and archives one. As with the other console screens, the same path serves the existing JSON API to a bearer-token client unchanged. **Added:** *Subscription-plans management screen* — `/admin/subscription-plans` renders the plan catalog (price and interval, trial length, Stripe price id, linked variant, status) with an active / archived filter, when opened in a signed-in browser; the same path serves the JSON list to a bearer-token client. A form creates a plan — Stripe price id, interval, interval count, currency, amount, trial days, and an optional variant link — and each active row archives in one submit. A bad-shape create re-renders the form with the validator's message rather than a 500, and archiving an unknown plan is a no-op notice. Archiving is terminal from the console: because a plan mirrors a Stripe price id that can go stale, a retired plan is re-offered by creating a new one against a fresh price id. **Changed:** *Console nav gains Subscriptions* — The signed-in admin nav now includes Subscriptions alongside Products, Inventory, Orders, Returns, and Reviews, shown when the subscriptions primitive is wired. The `/admin/subscription-plans` list, create, and archive 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 returns the sign-in form on a GET and redirects on a write.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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; **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. The Returns, Reviews, and Subscriptions 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
@@ -225,7 +225,7 @@ function mount(router, deps) {
225
225
  // Which optional console sections are wired — gates their nav links so a
226
226
  // signed-in admin is never sent to a route that wasn't mounted. Passed
227
227
  // into every authed render call as `nav_available`.
228
- var navAvailable = { returns: !!returns, reviews: !!reviews };
228
+ var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions };
229
229
 
230
230
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
231
231
 
@@ -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) {
@@ -1224,22 +1292,69 @@ function mount(router, deps) {
1224
1292
 
1225
1293
  var subscriptions = deps.subscriptions || null;
1226
1294
  if (subscriptions) {
1227
- router.post("/admin/subscription-plans", W("subscription_plan.create", async function (req, res) {
1228
- var p = await subscriptions.plans.create(req.body || {});
1229
- _json(res, 201, p);
1230
- return p;
1231
- }));
1295
+ // Create content-negotiates: bearer JSON (unchanged for tooling);
1296
+ // signed-in browser form create, then PRG back to the catalog (a
1297
+ // bad-shape submit re-renders the form with the validator's message
1298
+ // rather than 500-ing).
1299
+ router.post("/admin/subscription-plans", _pageOrApi(false,
1300
+ W("subscription_plan.create", async function (req, res) {
1301
+ var p = await subscriptions.plans.create(req.body || {});
1302
+ _json(res, 201, p);
1303
+ return p;
1304
+ }),
1305
+ async function (req, res) {
1306
+ var body = req.body || {};
1307
+ var input = {
1308
+ stripe_price_id: typeof body.stripe_price_id === "string" ? body.stripe_price_id.trim() : body.stripe_price_id,
1309
+ interval: body.interval,
1310
+ currency: typeof body.currency === "string" ? body.currency.trim().toLowerCase() : body.currency,
1311
+ };
1312
+ if (body.amount_minor != null && body.amount_minor !== "") input.amount_minor = parseInt(body.amount_minor, 10);
1313
+ if (body.interval_count != null && body.interval_count !== "") input.interval_count = parseInt(body.interval_count, 10);
1314
+ if (body.trial_days != null && body.trial_days !== "") input.trial_days = parseInt(body.trial_days, 10);
1315
+ if (body.variant_id) input.variant_id = String(body.variant_id).trim();
1316
+ try {
1317
+ await subscriptions.plans.create(input);
1318
+ } catch (e) {
1319
+ if (!(e instanceof TypeError)) throw e;
1320
+ var rows = await subscriptions.plans.list({});
1321
+ return _sendHtml(res, 400, renderAdminSubscriptionPlans({
1322
+ shop_name: deps.shop_name, nav_available: navAvailable, plans: rows,
1323
+ notice: e.message.replace(/^subscriptions[.:]\s*/, ""),
1324
+ }));
1325
+ }
1326
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".subscription_plan.create", outcome: "success" });
1327
+ _redirect(res, "/admin/subscription-plans?created=1");
1328
+ },
1329
+ ));
1232
1330
 
1233
- router.get("/admin/subscription-plans", R(async function (req, res) {
1234
- var url = req.url ? new URL(req.url, "http://localhost") : null;
1235
- var variantId = url && url.searchParams.get("variant_id");
1236
- var activeS = url && url.searchParams.get("active");
1237
- var filter = {};
1238
- if (variantId) filter.variant_id = variantId;
1239
- if (activeS != null) filter.active = activeS === "1" || activeS === "true";
1240
- var rows = await subscriptions.plans.list(filter);
1241
- _json(res, 200, { rows: rows });
1242
- }));
1331
+ router.get("/admin/subscription-plans", _pageOrApi(true,
1332
+ R(async function (req, res) {
1333
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1334
+ var variantId = url && url.searchParams.get("variant_id");
1335
+ var activeS = url && url.searchParams.get("active");
1336
+ var filter = {};
1337
+ if (variantId) filter.variant_id = variantId;
1338
+ if (activeS != null) filter.active = activeS === "1" || activeS === "true";
1339
+ var rows = await subscriptions.plans.list(filter);
1340
+ _json(res, 200, { rows: rows });
1341
+ }),
1342
+ async function (req, res) {
1343
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1344
+ var activeS = url && url.searchParams.get("active");
1345
+ var filter = {};
1346
+ if (activeS === "1" || activeS === "true") filter.active = true;
1347
+ else if (activeS === "0" || activeS === "false") filter.active = false;
1348
+ var rows = await subscriptions.plans.list(filter);
1349
+ _sendHtml(res, 200, renderAdminSubscriptionPlans({
1350
+ shop_name: deps.shop_name, nav_available: navAvailable, plans: rows,
1351
+ active_filter: activeS,
1352
+ created: url && url.searchParams.get("created"),
1353
+ archived: url && url.searchParams.get("archived"),
1354
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the plan." : null,
1355
+ }));
1356
+ },
1357
+ ));
1243
1358
 
1244
1359
  router.get("/admin/subscription-plans/:id", R(async function (req, res) {
1245
1360
  var p = await subscriptions.plans.get(req.params.id);
@@ -1254,12 +1369,25 @@ function mount(router, deps) {
1254
1369
  return p;
1255
1370
  }));
1256
1371
 
1257
- router.post("/admin/subscription-plans/:id/archive", W("subscription_plan.archive", async function (req, res) {
1258
- var p = await subscriptions.plans.archive(req.params.id);
1259
- if (!p) return _problem(res, 404, "subscription-plan-not-found");
1260
- _json(res, 200, p);
1261
- return p;
1262
- }));
1372
+ // Archive content-negotiates: bearer JSON; browser form → archive,
1373
+ // then PRG. An unknown / malformed id is a no-op notice (?err=1),
1374
+ // never a 500.
1375
+ router.post("/admin/subscription-plans/:id/archive", _pageOrApi(false,
1376
+ W("subscription_plan.archive", async function (req, res) {
1377
+ var p = await subscriptions.plans.archive(req.params.id);
1378
+ if (!p) return _problem(res, 404, "subscription-plan-not-found");
1379
+ _json(res, 200, p);
1380
+ return p;
1381
+ }),
1382
+ async function (req, res) {
1383
+ var p = null;
1384
+ try { p = await subscriptions.plans.archive(req.params.id); }
1385
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1386
+ if (!p) return _redirect(res, "/admin/subscription-plans?err=1");
1387
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".subscription_plan.archive", outcome: "success", metadata: { id: req.params.id } });
1388
+ _redirect(res, "/admin/subscription-plans?archived=1");
1389
+ },
1390
+ ));
1263
1391
 
1264
1392
  router.get("/admin/subscriptions", R(async function (req, res) {
1265
1393
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -1278,13 +1406,21 @@ function mount(router, deps) {
1278
1406
  _json(res, 200, s);
1279
1407
  }));
1280
1408
 
1281
- router.post("/admin/subscriptions/:id/cancel", W("subscription.cancel", async function (req, res) {
1282
- var body = req.body || {};
1283
- var s = await subscriptions.subscriptions.cancel(req.params.id, { at_period_end: !!body.at_period_end });
1284
- if (!s) return _problem(res, 404, "subscription-not-found");
1285
- _json(res, 200, s);
1286
- return s;
1287
- }));
1409
+ // Cancelling composes the Stripe API (payment.subscriptions.cancel),
1410
+ // so the route only mounts when a payment handle is wired — exactly
1411
+ // like the refund routes. Without Stripe it stays unmounted (404
1412
+ // "feature unavailable") rather than 400-ing with internal error
1413
+ // text when the handler dereferences a null payment. Plan CRUD and
1414
+ // the read-only instance views above need no Stripe and always mount.
1415
+ if (payment) {
1416
+ router.post("/admin/subscriptions/:id/cancel", W("subscription.cancel", async function (req, res) {
1417
+ var body = req.body || {};
1418
+ var s = await subscriptions.subscriptions.cancel(req.params.id, { at_period_end: !!body.at_period_end });
1419
+ if (!s) return _problem(res, 404, "subscription-not-found");
1420
+ _json(res, 200, s);
1421
+ return s;
1422
+ }));
1423
+ }
1288
1424
  }
1289
1425
 
1290
1426
  // ---- admin web pages (browser session + setup wizard) ---------------
@@ -1497,6 +1633,9 @@ var DASHBOARD_LAYOUT =
1497
1633
  " .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
1498
1634
  " .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
1499
1635
  " .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1636
+ " .inv-row-form { display:flex; gap:.4rem; align-items:center; }\n" +
1637
+ " .inv-row-form input { padding:.4rem .5rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1638
+ " tr.row--low td { background:#fff8e1; }\n" +
1500
1639
  " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1501
1640
  " .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
1641
  " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
@@ -1677,9 +1816,11 @@ var ADMIN_NAV_ITEMS = [
1677
1816
  { key: "home", href: "/admin", label: "Home" },
1678
1817
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
1679
1818
  { key: "products", href: "/admin/products", label: "Products" },
1819
+ { key: "inventory", href: "/admin/inventory", label: "Inventory" },
1680
1820
  { key: "orders", href: "/admin/orders", label: "Orders" },
1681
1821
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1682
1822
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
1823
+ { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
1683
1824
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1684
1825
  { key: "setup", href: "/admin/setup", label: "Setup" },
1685
1826
  ];
@@ -2178,6 +2319,121 @@ function renderAdminReviews(opts) {
2178
2319
  return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
2179
2320
  }
2180
2321
 
2322
+ function renderAdminInventory(opts) {
2323
+ opts = opts || {};
2324
+ var rows = opts.inventory || [];
2325
+ var created = opts.created ? "<div class=\"banner banner--ok\">SKU created.</div>" : "";
2326
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Inventory updated.</div>" : "";
2327
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2328
+
2329
+ var chips = "<div class=\"order-filters\">" +
2330
+ "<a class=\"chip" + (opts.low ? "" : " chip--on") + "\" href=\"/admin/inventory\">All</a>" +
2331
+ "<a class=\"chip" + (opts.low ? " chip--on" : "") + "\" href=\"/admin/inventory?low=1\">Low stock</a>" +
2332
+ "</div>";
2333
+
2334
+ var body = rows.map(function (r) {
2335
+ var available = (r.stock_on_hand || 0) - (r.stock_held || 0);
2336
+ var th = r.low_stock_threshold;
2337
+ var isLow = th != null && available <= th;
2338
+ var thVal = th == null ? "" : String(th);
2339
+ return "<tr" + (isLow ? " class=\"row--low\"" : "") + ">" +
2340
+ "<td><code class=\"order-id\">" + _htmlEscape(r.sku) + "</code>" + (isLow ? " <span class=\"status-pill pending\">low</span>" : "") + "</td>" +
2341
+ "<td class=\"num\">" + _htmlEscape(String(r.stock_on_hand)) + "</td>" +
2342
+ "<td class=\"num\">" + _htmlEscape(String(r.stock_held)) + "</td>" +
2343
+ "<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
2344
+ "<td>" +
2345
+ "<form method=\"post\" action=\"/admin/inventory/" + _htmlEscape(r.sku) + "/restock\" class=\"inv-row-form\">" +
2346
+ "<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" style=\"width:6rem;\">" +
2347
+ "<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" style=\"width:6rem;\">" +
2348
+ "<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
2349
+ "</form>" +
2350
+ "</td></tr>";
2351
+ }).join("");
2352
+
2353
+ var table = rows.length
2354
+ ? "<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>"
2355
+ : "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
2356
+
2357
+ var createForm =
2358
+ "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2359
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Track a new SKU</h3>" +
2360
+ "<form method=\"post\" action=\"/admin/inventory\">" +
2361
+ _setupField("SKU", "sku", "", "text", "Must match a variant SKU.", " maxlength=\"128\" required") +
2362
+ _setupField("Starting stock on hand", "stock_on_hand", "0", "number", "", " min=\"0\"") +
2363
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Track SKU</button></div>" +
2364
+ "</form>" +
2365
+ "</div>";
2366
+
2367
+ var bodyHtml = "<section><h2>Inventory</h2>" + created + updated + notice + chips + table + createForm + "</section>";
2368
+ return _renderAdminShell(opts.shop_name, "Inventory", bodyHtml, "inventory", opts.nav_available);
2369
+ }
2370
+
2371
+ function renderAdminSubscriptionPlans(opts) {
2372
+ opts = opts || {};
2373
+ var rows = opts.plans || [];
2374
+ var created = opts.created ? "<div class=\"banner banner--ok\">Plan created.</div>" : "";
2375
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Plan archived.</div>" : "";
2376
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2377
+
2378
+ var af = opts.active_filter;
2379
+ var chips = "<div class=\"order-filters\">" +
2380
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/subscription-plans\">All</a>" +
2381
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/subscription-plans?active=1\">Active</a>" +
2382
+ "<a class=\"chip" + (af === "0" ? " chip--on" : "") + "\" href=\"/admin/subscription-plans?active=0\">Archived</a>" +
2383
+ "</div>";
2384
+
2385
+ // Each plan mirrors a recurring Stripe Price (Stripe stays the pricing
2386
+ // source of truth). Archiving is terminal from the console — because the
2387
+ // mirrored Stripe price id may go stale, a retired plan is re-offered by
2388
+ // creating a new one against a fresh price id, never reactivated in place.
2389
+ var bodyRows = rows.map(function (p) {
2390
+ var every = p.interval_count > 1 ? p.interval_count + " " + p.interval + "s" : p.interval;
2391
+ // Plans store currency lowercase (the subscriptions validator's form);
2392
+ // pricing.format wants the uppercase ISO 4217 code.
2393
+ var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
2394
+ var isActive = p.active === 1 || p.active === true;
2395
+ var archiveCell = isActive
2396
+ ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" style=\"display:inline;\">" +
2397
+ "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
2398
+ : "<span class=\"meta\">—</span>";
2399
+ return "<tr>" +
2400
+ "<td><strong>" + _htmlEscape(price) + "</strong>" +
2401
+ (p.trial_days ? " <span class=\"status-pill pending\">" + _htmlEscape(String(p.trial_days)) + "d trial</span>" : "") + "</td>" +
2402
+ "<td><code class=\"order-id\">" + _htmlEscape(p.stripe_price_id) + "</code></td>" +
2403
+ "<td>" + (p.variant_id ? "<code class=\"order-id\">" + _htmlEscape(String(p.variant_id).slice(0, 8)) + "</code>" : "<span class=\"meta\">standalone</span>") + "</td>" +
2404
+ "<td><span class=\"status-pill " + (isActive ? "paid" : "cancelled") + "\">" + (isActive ? "active" : "archived") + "</span></td>" +
2405
+ "<td>" + archiveCell + "</td>" +
2406
+ "</tr>";
2407
+ }).join("");
2408
+
2409
+ var table = rows.length
2410
+ ? "<div class=\"panel\"><table><thead><tr><th>Price / interval</th><th>Stripe price</th><th>Variant</th><th>Status</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
2411
+ : "<p class=\"empty\">No subscription plans" + (af === "0" ? " archived" : af === "1" ? " active" : " yet") + ".</p>";
2412
+
2413
+ var intervalOpts = ["month", "year", "week", "day"].map(function (iv) {
2414
+ return "<option value=\"" + iv + "\">" + iv + "</option>";
2415
+ }).join("");
2416
+
2417
+ var createForm =
2418
+ "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2419
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Create a plan</h3>" +
2420
+ "<p class=\"meta\">Pre-create the recurring Price in Stripe, then mirror it here so the storefront can render the plan without a network hop.</p>" +
2421
+ "<form method=\"post\" action=\"/admin/subscription-plans\">" +
2422
+ _setupField("Stripe price id", "stripe_price_id", "", "text", "The recurring Price id from your Stripe dashboard (e.g. price_…).", " maxlength=\"255\" required") +
2423
+ "<label class=\"form-field\"><span>Billing interval</span><select name=\"interval\">" + intervalOpts + "</select></label>" +
2424
+ _setupField("Interval count", "interval_count", "1", "number", "Bill every N intervals (1–12).", " min=\"1\" max=\"12\"") +
2425
+ _setupField("Currency", "currency", "", "text", "3-letter ISO 4217 (e.g. USD).", " maxlength=\"3\" required") +
2426
+ _setupField("Amount (minor units)", "amount_minor", "", "number", "In the currency's smallest unit — e.g. 1999 = $19.99.", " min=\"1\" required") +
2427
+ _setupField("Trial days", "trial_days", "0", "number", "Free trial length before the first charge (0–730).", " min=\"0\" max=\"730\"") +
2428
+ _setupField("Variant id (optional)", "variant_id", "", "text", "Link to a storefront variant, or leave blank for a standalone tier.", " maxlength=\"64\"") +
2429
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create plan</button></div>" +
2430
+ "</form>" +
2431
+ "</div>";
2432
+
2433
+ var bodyHtml = "<section><h2>Subscription plans</h2>" + created + archived + notice + chips + table + createForm + "</section>";
2434
+ return _renderAdminShell(opts.shop_name, "Subscription plans", bodyHtml, "subscriptions", opts.nav_available);
2435
+ }
2436
+
2181
2437
  module.exports = {
2182
2438
  mount: mount,
2183
2439
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
@@ -2187,6 +2443,7 @@ module.exports = {
2187
2443
  renderAdminSetup: renderAdminSetup,
2188
2444
  renderAdminIntegrations: renderAdminIntegrations,
2189
2445
  renderAdminProducts: renderAdminProducts,
2446
+ renderAdminInventory: renderAdminInventory,
2190
2447
  renderAdminOrders: renderAdminOrders,
2191
2448
  renderAdminOrder: renderAdminOrder,
2192
2449
  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
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.12.55",
7
- "tag": "v0.12.55",
6
+ "version": "0.12.56",
7
+ "tag": "v0.12.56",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.56 (2026-05-25) — **`b.canonicalJson` — RFC 8785 JSON Canonicalization Scheme, now a public primitive.** The deterministic JSON serializer the framework uses internally for audit-chain and config-drift fingerprints is now an operator-facing primitive, for signing your own JSON (custom credentials, receipts, deterministic request signing). b.canonicalJson.stringifyJcs is strict RFC 8785: keys sorted in UTF-16 code-unit order at every depth, numbers in the ECMAScript format JCS references, and types JCS does not define (BigInt / Buffer / Date / Map / Set / circular references) refused rather than silently lost. b.canonicalJson.stringify is a lenient variant that also serializes Buffers (hex), Dates (ISO-8601), and BigInts. Exposing it surfaced and fixed a latent ordering bug: the serializer built a sorted-key object and let JSON.stringify emit it, but V8 hoists integer-like keys ("1", "10") to the front — so canonical output was wrong for objects with integer-like string keys. Members are now written in sorted order directly. Validated against the official cyberphone/json-canonicalization conformance vectors. **Added:** *`b.canonicalJson.stringifyJcs(value)` / `stringify(value, opts?)` / `sortKeys(obj)`* — `stringifyJcs` produces strict RFC 8785 canonical JSON — the byte-for-byte stable form to hash or sign — with UTF-16 code-unit key sorting and ECMAScript number formatting, refusing BigInt / Buffer / Date / Map / Set / RegExp / Symbol / function / circular references. `stringify` is the lenient framework variant (Buffers → hex, Dates → ISO-8601, BigInts → decimal; `opts.bufferAs: "reject"` to forbid binary). `sortKeys` returns an object's own keys in the canonical UTF-16 ordering. These were framework-internal; they are now documented public API. **Fixed:** *Canonical JSON now emits integer-like keys in sorted order* — The canonical serializer built a sorted-key object and serialized it with JSON.stringify, which hoists integer-like string keys ("1", "10") to the front per V8 own-property ordering — producing non-canonical output for objects containing such keys (a violation of RFC 8785 §3.2.3). Members are now written in sorted-key order directly. Real-world consumers (audit-chain, config-drift) use named fields and are unaffected; only objects with integer-like string keys change, and the new output is the correct canonical form.
12
+
11
13
  - v0.12.55 (2026-05-25) — **`b.structuredFields` — RFC 9651 Date and Display String types.** Brings the Structured Fields codec up to RFC 9651, which obsoletes RFC 8941 by adding two bare-item types. A Date (`@1659578233`) is an Integer number of seconds since the Unix epoch; a Display String (`%"f%c3%bc%c3%bc"`) is a Unicode string conveyed as percent-escaped UTF-8. parse returns them as distinct SfDate / SfDisplayString values, and serialize emits them canonically — a Date as `@` + integer, a Display String as `%"`-wrapped lowercase-percent-escaped UTF-8 that escapes only what RFC 9651 requires. Parsing is strict: a Date rejects a decimal / out-of-range value, and a Display String rejects uppercase escapes, raw non-ASCII, bad hex, and invalid UTF-8. Validated against the official httpwg structured-field-tests date and display-string vectors. **Added:** *RFC 9651 Date (`@…`) and Display String (`%"…"`) in `b.structuredFields`* — `parse` now reads the two RFC 9651 types: `@` + an Integer yields an `SfDate` (rejecting a decimal `@1.5`, an empty `@`, a sign-only `@-`, and out-of-range values), and `%"…"` yields an `SfDisplayString` (decoding lowercase `%XX` escapes as UTF-8, rejecting uppercase escapes, raw non-ASCII or control characters, malformed hex, and invalid UTF-8). `serialize` is the inverse — a Date as `@` + the integer, a Display String percent-escaping only non-printable / non-ASCII bytes plus `%` and `"`. The new `b.structuredFields.Date` and `b.structuredFields.DisplayString` wrappers construct these values. The module now tracks RFC 9651 (which obsoletes RFC 8941); the existing Item / List / Dictionary parsing is unchanged.
12
14
 
13
15
  - v0.12.54 (2026-05-25) — **`b.structuredFields.parse` / `serialize` — full RFC 8941 Structured Fields codec.** The structured-fields module gains a complete RFC 8941 parser and serializer alongside its existing quote-aware helpers. b.structuredFields.parse reads an Item, List, or Dictionary into a typed value model — items are { value, params }, lists are arrays of items / inner lists, dictionaries are Maps — with Tokens and byte sequences returned as distinct SfToken / SfByteSequence instances. It enforces the grammar strictly: integer and decimal digit caps, printable-ASCII strings, canonical base64 byte sequences, valid token and key grammar, and no trailing characters. b.structuredFields.serialize is the exact inverse. This is the real parser the framework's Content-Digest, Client Hints, Web Push, and HTTP Message Signature surfaces can build on instead of open-coding each field. Validated against the official httpwg structured-field-tests conformance vectors. **Added:** *`b.structuredFields.parse(input, type, opts?)` / `serialize(value, type, opts?)` / `Token` / `ByteSequence`* — `parse` accepts `type` of `"item"`, `"list"`, or `"dictionary"` and returns the value model (items as `{ value, params }` with a `Map` of parameters; lists as arrays of items or inner lists; dictionaries as `Map`s). Bare items are JS numbers (Integer / Decimal), strings, booleans, `SfToken`, or `SfByteSequence`. Malformed input is rejected — out-of-range integers, over-long decimals, non-printable string bytes, non-canonical base64, invalid tokens / keys, and any trailing characters — and `opts.ErrorClass` yields a typed error. `serialize` is the inverse, rounding decimals to three fractional digits and refusing values outside the RFC's ranges or grammar. `b.structuredFields.Token` and `b.structuredFields.ByteSequence` wrap those bare-item types for serialization. The existing `splitTopLevel` / `refuseControlBytes` / `unquoteSfString` helpers are unchanged.
@@ -98,6 +98,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
98
98
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
99
99
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
100
100
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
101
+ - **Canonical JSON** — RFC 8785 JSON Canonicalization Scheme (`b.canonicalJson.stringifyJcs`): the deterministic, sorted-key byte form to hash or sign (custom credentials, receipts, deterministic request signing); UTF-16 key ordering + ECMAScript number formatting, with a lenient `stringify` variant for Buffers / Dates / BigInts
101
102
  - **Structured Fields** — full RFC 9651 codec (`b.structuredFields.parse` / `serialize`): Items / Lists / Dictionaries, Inner Lists, Parameters, and every bare-item type (Integer / Decimal / String / Token / Byte Sequence / Boolean / Date / Display String) with strict grammar + range enforcement — the parser behind Content-Digest, Client Hints, and HTTP Message Signatures
102
103
  - **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
103
104
  - **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.55",
4
- "createdAt": "2026-05-25T19:45:48.985Z",
3
+ "frameworkVersion": "0.12.56",
4
+ "createdAt": "2026-05-25T21:09:07.457Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -6005,6 +6005,23 @@
6005
6005
  }
6006
6006
  }
6007
6007
  },
6008
+ "canonicalJson": {
6009
+ "type": "object",
6010
+ "members": {
6011
+ "sortKeys": {
6012
+ "type": "function",
6013
+ "arity": 1
6014
+ },
6015
+ "stringify": {
6016
+ "type": "function",
6017
+ "arity": 2
6018
+ },
6019
+ "stringifyJcs": {
6020
+ "type": "function",
6021
+ "arity": 1
6022
+ }
6023
+ }
6024
+ },
6008
6025
  "cbor": {
6009
6026
  "type": "object",
6010
6027
  "members": {
@@ -396,6 +396,7 @@ var dbsc = require("./lib/dbsc");
396
396
  var importmapIntegrity = require("./lib/importmap-integrity");
397
397
  var privacyPass = require("./lib/privacy-pass");
398
398
  var contentDigest = require("./lib/content-digest");
399
+ var canonicalJson = require("./lib/canonical-json");
399
400
  var standardWebhooks = require("./lib/standard-webhooks");
400
401
  var lro = require("./lib/lro");
401
402
  var jsonApi = require("./lib/jsonapi");
@@ -413,6 +414,7 @@ module.exports = {
413
414
  importmapIntegrity: importmapIntegrity,
414
415
  privacyPass: privacyPass,
415
416
  contentDigest: contentDigest,
417
+ canonicalJson: canonicalJson,
416
418
  standardWebhooks: standardWebhooks,
417
419
  lro: lro,
418
420
  jsonApi: jsonApi,
@@ -1,79 +1,83 @@
1
1
  "use strict";
2
2
  /**
3
- * Canonical JSON — deterministic stringify with sorted keys at every depth.
3
+ * @module b.canonicalJson
4
+ * @nav Data
5
+ * @title Canonical JSON
4
6
  *
5
- * Replaces the four near-identical implementations that grew up across
6
- * `lib/audit-chain.js`, `lib/audit-tools.js`, `lib/config-drift.js`, and
7
- * `lib/pagination.js`. They all walked `typeof === "object"` with
8
- * `Object.keys(...).sort()` and silently round-tripped Date as `{}`,
9
- * Buffer as `{"0":97,"1":98,…}`, Map / Set / RegExp as `{}`, Symbol /
10
- * function as missing keys, and BigInt as a thrown
11
- * `Do not know how to serialize a BigInt` mid-emit. Circular references
12
- * stack-overflowed instead of producing a clean framework error.
7
+ * @intro
8
+ * Deterministic JSON serialization with keys sorted at every depth —
9
+ * the byte-for-byte stable form you hash or sign so two parties that
10
+ * build the same data produce the same bytes. <code>stringifyJcs</code>
11
+ * is strict RFC 8785 (JSON Canonicalization Scheme); <code>stringify</code>
12
+ * is a lenient variant that additionally serializes Buffers (as hex),
13
+ * Dates (ISO-8601), and BigInts (decimal) for the framework's own audit
14
+ * / config-drift fingerprints.
13
15
  *
14
- * The walk:
16
+ * Both walks close the silent-data-loss class that ad-hoc
17
+ * <code>Object.keys(...).sort()</code> serializers fall into: Map /
18
+ * Set / RegExp / class instances, Symbols, functions, and circular
19
+ * references all throw a clean error rather than emitting <code>{}</code>
20
+ * or stack-overflowing. RFC 8785 strict mode additionally refuses
21
+ * BigInt / Buffer / Date (types JCS does not define) so the operator
22
+ * converts them to JSON-native shapes before signing.
15
23
  *
16
- * primitives + null + undefined → JSON.stringify (undefined → "null")
17
- * bigint → decimal string ("123" not 123n)
18
- * Date → ISO string
19
- * Buffer / Uint8Array → hex (when bufferAs = "hex", default)
20
- * throw (when bufferAs = "reject")
21
- * Map / Set / RegExp → throw with constructor name
22
- * symbol / function → throw with type name
23
- * circular reference → throw via WeakSet detection
24
- * plain array → recurse, preserve order
25
- * plain object → recurse with sorted keys
24
+ * Key ordering is V8's <code>Object.keys(...).sort()</code>
25
+ * lexicographic UTF-16 code-unit order, which is exactly RFC 8785
26
+ * §3.2.3 and numbers are formatted by <code>JSON.stringify</code>,
27
+ * whose output is the ECMA-262 Number-to-string algorithm that RFC
28
+ * 8785 §3.2.2.3 references.
26
29
  *
27
- * Two consumer policies on Buffer / Uint8Array are documented because
28
- * the framework historically chose differently per call site:
29
- *
30
- * bufferAs: "hex" audit-chain / audit-tools / config-drift
31
- * binary data is legitimate (cert PEMs, key
32
- * material, hash bytes); preserve as hex so the
33
- * canonical output is reversible.
34
- * bufferAs: "reject" pagination — cursor state is operator-supplied
35
- * primitive data; binary in a cursor is almost
36
- * always a bug; reject loudly.
37
- *
38
- * Operators don't call this directly — it's a framework-internal walker.
30
+ * @card
31
+ * Canonical JSON (RFC 8785 JCS) the deterministic, sorted-key byte
32
+ * form you sign or hash. Strict <code>stringifyJcs</code> for
33
+ * interop, plus a lenient framework variant that serializes Buffers /
34
+ * Dates / BigInts. Lossy ad-hoc serializers (Map / Set / circular →
35
+ * <code>{}</code>) are refused.
39
36
  */
40
37
 
41
- function _scrub(value, seen, bufferAs) {
42
- if (value === null || typeof value === "undefined") return null;
38
+ // Emit the canonical JSON STRING in one ordered pass. Object members are
39
+ // written in sorted-key order directly building a plain object and
40
+ // relying on JSON.stringify would silently hoist integer-like keys
41
+ // ("1", "10") to the front (V8 own-property ordering), breaking the
42
+ // RFC 8785 §3.2.3 sort. Primitives, strings, and numbers use
43
+ // JSON.stringify, whose escaping (§3.2.2.2) and ECMAScript number format
44
+ // (§3.2.2.3) are exactly what JCS references.
45
+ function _emit(value, seen, bufferAs) {
46
+ if (value === null || typeof value === "undefined") return "null";
43
47
  var t = typeof value;
44
- if (t === "string" || t === "boolean" || t === "number") return value;
48
+ if (t === "number" || t === "string" || t === "boolean") return JSON.stringify(value);
45
49
  if (t === "bigint") {
46
50
  if (bufferAs === "reject-jcs") {
47
51
  throw new Error("canonical-json: BigInt is not serialisable under " +
48
52
  "RFC 8785 (JCS); convert to a string or number before passing in");
49
53
  }
50
- return String(value);
54
+ return JSON.stringify(String(value));
51
55
  }
52
56
  if (t === "symbol" || t === "function") {
53
57
  throw new Error("canonical-json: " + t + " value is not " +
54
58
  "serialisable; convert to a string before passing in");
55
59
  }
56
60
  // Buffer / Uint8Array — policy-driven
57
- if (Buffer.isBuffer(value)) {
61
+ if (Buffer.isBuffer(value)) {
58
62
  if (bufferAs === "reject" || bufferAs === "reject-jcs") {
59
63
  throw new Error("canonical-json: Buffer is not serialisable in this " +
60
64
  "context (bufferAs=reject); convert to a string or hex first");
61
65
  }
62
- return value.toString("hex");
66
+ return JSON.stringify(value.toString("hex"));
63
67
  }
64
68
  if (value instanceof Uint8Array) {
65
69
  if (bufferAs === "reject" || bufferAs === "reject-jcs") {
66
70
  throw new Error("canonical-json: Uint8Array is not serialisable in " +
67
71
  "this context (bufferAs=reject); convert to a string or hex first");
68
72
  }
69
- return Buffer.from(value).toString("hex");
73
+ return JSON.stringify(Buffer.from(value).toString("hex"));
70
74
  }
71
75
  if (value instanceof Date) {
72
76
  if (bufferAs === "reject-jcs") {
73
77
  throw new Error("canonical-json: Date is not serialisable under " +
74
78
  "RFC 8785 (JCS); convert to ISO-8601 string before passing in");
75
79
  }
76
- return value.toISOString();
80
+ return JSON.stringify(value.toISOString());
77
81
  }
78
82
  // After primitives + Date + Buffer + Uint8Array, any remaining "object"
79
83
  // must be a plain object or array. Map / Set / RegExp / class instances
@@ -88,35 +92,73 @@ function _scrub(value, seen, bufferAs) {
88
92
  }
89
93
  seen.add(value);
90
94
  if (Array.isArray(value)) {
91
- return value.map(function (v) { return _scrub(v, seen, bufferAs); });
95
+ // Index loop, not .map(): map() skips holes in a sparse array,
96
+ // which join() would then render as invalid elisions ([,1]). A hole
97
+ // reads as undefined → _emit returns "null" (matching JSON.stringify).
98
+ var items = [];
99
+ for (var ai = 0; ai < value.length; ai += 1) {
100
+ items.push(_emit(value[ai], seen, bufferAs));
101
+ }
102
+ return "[" + items.join(",") + "]";
92
103
  }
93
104
  // Canonical-json IS the destination for sorted-keys walks across the
94
105
  // codebase; the keys-then-sort here is the canonical primitive itself.
95
106
  var keys = Object.keys(value);
96
107
  keys.sort();
97
- var out = {};
108
+ var parts = [];
98
109
  for (var i = 0; i < keys.length; i++) {
99
- out[keys[i]] = _scrub(value[keys[i]], seen, bufferAs);
110
+ parts.push(JSON.stringify(keys[i]) + ":" + _emit(value[keys[i]], seen, bufferAs));
100
111
  }
101
- return out;
112
+ return "{" + parts.join(",") + "}";
102
113
  }
103
114
 
104
- // Return the deterministic JSON string. opts.bufferAs picks the Buffer
105
- // policy ("hex" default, "reject" for callers like pagination).
115
+ /**
116
+ * @primitive b.canonicalJson.stringify
117
+ * @signature b.canonicalJson.stringify(value, opts?)
118
+ * @since 0.5.0
119
+ * @status stable
120
+ * @related b.canonicalJson.stringifyJcs, b.canonicalJson.sortKeys
121
+ *
122
+ * Deterministic JSON with keys sorted at every depth — the lenient
123
+ * framework variant. Beyond JSON-native values it serializes Buffers /
124
+ * Uint8Arrays (hex), Dates (ISO-8601), and BigInts (decimal string); Map
125
+ * / Set / RegExp / class instances, Symbols, functions, and circular
126
+ * references throw rather than silently emitting <code>{}</code>. Use
127
+ * <code>stringifyJcs</code> for strict RFC 8785 interop.
128
+ *
129
+ * @opts
130
+ * bufferAs: string, // "hex" (default) | "reject" — Buffer / Uint8Array policy
131
+ *
132
+ * @example
133
+ * b.canonicalJson.stringify({ b: 1, a: 2 });
134
+ * // → '{"a":2,"b":1}'
135
+ */
106
136
  function stringify(value, opts) {
107
137
  var bufferAs = (opts && opts.bufferAs) || "hex";
108
138
  if (bufferAs !== "hex" && bufferAs !== "reject" && bufferAs !== "reject-jcs") {
109
139
  throw new Error("canonical-json: bufferAs must be 'hex' / 'reject' / 'reject-jcs'; got " +
110
140
  JSON.stringify(bufferAs));
111
141
  }
112
- return JSON.stringify(_scrub(value, null, bufferAs));
142
+ return _emit(value, null, bufferAs);
113
143
  }
114
144
 
115
- // Stable key ordering for an object — same lexicographic sort used by
116
- // the canonical-json walker. Exposed so call sites that need a sorted
117
- // key list (CLI report ordering, fingerprint inputs) route through
118
- // the framework's single source-of-truth ordering rule rather than
119
- // re-implementing the keys-then-sort dance inline.
145
+ /**
146
+ * @primitive b.canonicalJson.sortKeys
147
+ * @signature b.canonicalJson.sortKeys(obj)
148
+ * @since 0.5.0
149
+ * @status stable
150
+ * @related b.canonicalJson.stringify
151
+ *
152
+ * The object's own keys in the framework's single canonical ordering —
153
+ * lexicographic UTF-16 code-unit sort (the same ordering the canonical
154
+ * serializers use). Returns an empty array for a non-object. Route
155
+ * fingerprint / report ordering through this rather than re-implementing
156
+ * the keys-then-sort dance inline.
157
+ *
158
+ * @example
159
+ * b.canonicalJson.sortKeys({ b: 1, a: 2, c: 3 });
160
+ * // → ["a", "b", "c"]
161
+ */
120
162
  function sortKeys(obj) {
121
163
  if (!obj || typeof obj !== "object") return [];
122
164
  var keys = Object.keys(obj);
@@ -124,16 +166,30 @@ function sortKeys(obj) {
124
166
  return keys;
125
167
  }
126
168
 
127
- // stringifyJcs — RFC 8785 (JSON Canonicalization Scheme) strict mode.
128
- // Refuses inputs JCS does NOT cover (BigInt, Buffer / Uint8Array, Date,
129
- // Map, Set, RegExp, Symbol, function); operators carrying those types
130
- // must convert to JSON-native shapes upfront. Object key ordering and
131
- // number formatting already match JCS §3.2.2 — V8's
132
- // `Object.keys(...).sort()` is lexicographic UTF-16 code-unit order
133
- // (JCS §3.2.3) and `JSON.stringify` formats numbers per
134
- // ECMA-262 §7.1.12.1 which JCS §3.2.2.3 references.
169
+ /**
170
+ * @primitive b.canonicalJson.stringifyJcs
171
+ * @signature b.canonicalJson.stringifyJcs(value)
172
+ * @since 0.12.56
173
+ * @status stable
174
+ * @compliance soc2
175
+ * @related b.canonicalJson.stringify, b.vc.issue, b.scitt.signStatement
176
+ *
177
+ * Strict RFC 8785 JSON Canonicalization Scheme — the deterministic byte
178
+ * form to hash or sign when two parties must agree on the exact bytes
179
+ * (signed JSON credentials, receipts, deterministic request signing).
180
+ * Keys are sorted in UTF-16 code-unit order at every depth (§3.2.3) and
181
+ * numbers use the ECMAScript Number-to-string formatting §3.2.2.3
182
+ * references. Inputs JCS does not define — BigInt, Buffer / Uint8Array,
183
+ * Date, Map, Set, RegExp, Symbol, function, and circular references —
184
+ * are refused, so the operator converts them to JSON-native shapes
185
+ * before signing rather than getting a silently lossy result.
186
+ *
187
+ * @example
188
+ * b.canonicalJson.stringifyJcs({ "€": 1, "$": 2 });
189
+ * // → '{"$":2,"€":1}' (keys sorted by UTF-16 code unit)
190
+ */
135
191
  function stringifyJcs(value) {
136
- return JSON.stringify(_scrub(value, null, "reject-jcs"));
192
+ return _emit(value, null, "reject-jcs");
137
193
  }
138
194
 
139
195
  module.exports = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.55",
3
+ "version": "0.12.56",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.56",
4
+ "date": "2026-05-25",
5
+ "headline": "`b.canonicalJson` — RFC 8785 JSON Canonicalization Scheme, now a public primitive",
6
+ "summary": "The deterministic JSON serializer the framework uses internally for audit-chain and config-drift fingerprints is now an operator-facing primitive, for signing your own JSON (custom credentials, receipts, deterministic request signing). b.canonicalJson.stringifyJcs is strict RFC 8785: keys sorted in UTF-16 code-unit order at every depth, numbers in the ECMAScript format JCS references, and types JCS does not define (BigInt / Buffer / Date / Map / Set / circular references) refused rather than silently lost. b.canonicalJson.stringify is a lenient variant that also serializes Buffers (hex), Dates (ISO-8601), and BigInts. Exposing it surfaced and fixed a latent ordering bug: the serializer built a sorted-key object and let JSON.stringify emit it, but V8 hoists integer-like keys (\"1\", \"10\") to the front — so canonical output was wrong for objects with integer-like string keys. Members are now written in sorted order directly. Validated against the official cyberphone/json-canonicalization conformance vectors.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.canonicalJson.stringifyJcs(value)` / `stringify(value, opts?)` / `sortKeys(obj)`",
13
+ "body": "`stringifyJcs` produces strict RFC 8785 canonical JSON — the byte-for-byte stable form to hash or sign — with UTF-16 code-unit key sorting and ECMAScript number formatting, refusing BigInt / Buffer / Date / Map / Set / RegExp / Symbol / function / circular references. `stringify` is the lenient framework variant (Buffers → hex, Dates → ISO-8601, BigInts → decimal; `opts.bufferAs: \"reject\"` to forbid binary). `sortKeys` returns an object's own keys in the canonical UTF-16 ordering. These were framework-internal; they are now documented public API."
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "heading": "Fixed",
19
+ "items": [
20
+ {
21
+ "title": "Canonical JSON now emits integer-like keys in sorted order",
22
+ "body": "The canonical serializer built a sorted-key object and serialized it with JSON.stringify, which hoists integer-like string keys (\"1\", \"10\") to the front per V8 own-property ordering — producing non-canonical output for objects containing such keys (a violation of RFC 8785 §3.2.3). Members are now written in sorted-key order directly. Real-world consumers (audit-chain, config-drift) use named fields and are unaffected; only objects with integer-like string keys change, and the new output is the correct canonical form."
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.canonicalJson (RFC 8785 JSON Canonicalization Scheme).
4
+ * The oracle is the official cyberphone/json-canonicalization conformance
5
+ * suite: the structures / french / values vectors (nested key sort,
6
+ * locale-independent UTF-16 ordering, and the ECMAScript number format)
7
+ * must serialize byte-for-byte to the published output.
8
+ */
9
+
10
+ var b = require("../../index");
11
+ var helpers = require("../helpers");
12
+ var check = helpers.check;
13
+ var cj = b.canonicalJson;
14
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code || e.message; } }
15
+
16
+ function testSurface() {
17
+ check("b.canonicalJson.stringifyJcs is a function", typeof cj.stringifyJcs === "function");
18
+ check("b.canonicalJson.stringify is a function", typeof cj.stringify === "function");
19
+ check("b.canonicalJson.sortKeys returns the UTF-16 sorted keys", cj.sortKeys({ b: 1, a: 2, c: 3 }).join() === "a,b,c");
20
+ }
21
+
22
+ function testJcsConformance() {
23
+ // cyberphone/json-canonicalization testdata — "structures": nested
24
+ // key sort at every depth, 56.0 → 56.
25
+ var structures = { "1": { "f": { "f": "hi", "F": 5 }, "\n": 56.0 }, "10": {}, "": "empty", "a": {}, "111": [{ "e": "yes", "E": "no" }], "A": {} };
26
+ check("JCS: nested structures vector", cj.stringifyJcs(structures) === '{"":"empty","1":{"\\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}}');
27
+
28
+ // "french": sorting ignores locale (UTF-16 code-unit order).
29
+ var french = { "peach": "This sorting order", "péché": "is wrong according to French", "pêche": "but canonicalization MUST", "sin": "ignore locale" };
30
+ check("JCS: locale-independent french vector", cj.stringifyJcs(french) === '{"peach":"This sorting order","péché":"is wrong according to French","pêche":"but canonicalization MUST","sin":"ignore locale"}');
31
+
32
+ // "values" numbers: the ECMAScript Number-to-string format JCS §3.2.2.3 references.
33
+ // Number("…") avoids a loss-of-precision lint on the deliberately
34
+ // over-precise vector value (333333333.33333329 → 333333333.3333333).
35
+ check("JCS: number formatting vector", cj.stringifyJcs([Number("333333333.33333329"), 1e30, 4.50, 2e-3, Number("0.000000000000000000000000001")]) === "[333333333.3333333,1e+30,4.5,0.002,1e-27]");
36
+
37
+ // Astral key sorts by UTF-16 code unit: "$"(U+0024) < "€"(U+20AC) <
38
+ // "😂"(lead surrogate U+D83D).
39
+ check("JCS: astral key UTF-16 ordering", cj.stringifyJcs({ "€": 1, "$": 2, "😂": 3 }) === '{"$":2,"€":1,"😂":3}');
40
+
41
+ // Unnormalized Unicode is preserved, NOT normalized (A + combining ring
42
+ // stays two code points).
43
+ check("JCS: no Unicode normalization", cj.stringifyJcs({ k: "Å" }) === '{"k":"Å"}');
44
+ }
45
+
46
+ function testSparseArrays() {
47
+ // Sparse-array holes serialize as null, not invalid JSON elisions ([,1]).
48
+ var sparse = [1, , 3]; // eslint-disable-line no-sparse-arrays
49
+ check("JCS: sparse array holes → null", cj.stringifyJcs(sparse) === "[1,null,3]");
50
+ check("JCS: explicit undefined in array → null", cj.stringifyJcs([1, undefined, 3]) === "[1,null,3]");
51
+ }
52
+
53
+ function testStrictRefusals() {
54
+ check("JCS: BigInt refused", /BigInt/.test(code(function () { cj.stringifyJcs({ n: 1n }); })));
55
+ check("JCS: Buffer refused", /Buffer/.test(code(function () { cj.stringifyJcs({ b: Buffer.from("x") }); })));
56
+ check("JCS: Date refused", /Date/.test(code(function () { cj.stringifyJcs({ d: new Date() }); })));
57
+ check("JCS: Map refused", /Map/.test(code(function () { cj.stringifyJcs({ m: new Map() }); })));
58
+ check("JCS: circular reference refused", /circular/.test(code(function () { var o = {}; o.self = o; cj.stringifyJcs(o); })));
59
+ }
60
+
61
+ function testLenientStringify() {
62
+ // The lenient framework variant serializes Buffers (hex), Dates (ISO),
63
+ // and BigInts (decimal) while still sorting keys.
64
+ check("stringify: sorts keys", cj.stringify({ b: 1, a: 2 }) === '{"a":2,"b":1}');
65
+ check("stringify: Buffer → hex", cj.stringify({ k: Buffer.from("ab") }) === '{"k":"6162"}');
66
+ check("stringify: bufferAs reject throws", /reject/.test(code(function () { cj.stringify({ k: Buffer.from("x") }, { bufferAs: "reject" }); })));
67
+ }
68
+
69
+ async function run() {
70
+ testSurface();
71
+ testJcsConformance();
72
+ testSparseArrays();
73
+ testStrictRefusals();
74
+ testLenientStringify();
75
+ }
76
+
77
+ module.exports = { run: run };
78
+
79
+ if (require.main === module) {
80
+ run().then(
81
+ function () { console.log("[canonical-json] OK — " + helpers.getChecks() + " checks passed"); },
82
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
83
+ );
84
+ }
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.18",
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": {