@blamejs/blamejs-shop 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/lib/admin.js +262 -24
- package/lib/order.js +24 -18
- package/package.json +1 -1
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.19 (2026-05-25) — **Admin console — a webhooks screen, and order events now fan out.** Webhooks join the admin console. `/admin/webhooks` registers an endpoint (with a one-time signing-secret reveal), lists endpoints, enables / disables and deletes one, and opens an endpoint's delivery feed to retry a failed delivery. Order lifecycle transitions now fan out to registered endpoints — the order primitive is wired to the webhook dispatcher so a paid / fulfilled / shipped / delivered / cancelled / refunded transition produces a signed delivery. **Added:** *Webhooks management screen* — `/admin/webhooks` registers an outbound endpoint — an https:// URL plus the events to subscribe (or all) — and shows the HMAC-SHA3-512 signing secret once on creation; the secret is never rendered in the endpoint list afterward. The list shows each endpoint's URL, events, status, and per-minute rate, with actions to enable / disable, delete, and open its deliveries. The delivery feed lists each attempt (event, status, response code, attempt count, last error, time) and retries a failed delivery. The JSON API a bearer-token client already used — create / list / update / delete / deliveries / retry — is unchanged; the browser screen content-negotiates on the same paths. · *Order events fan out to webhooks* — The order primitive is now wired to the webhook dispatcher, so an order transition (`order.mark_paid`, `order.start_fulfillment`, `order.mark_shipped`, `order.mark_delivered`, `order.cancel`, `order.refund`) produces a signed delivery to every subscribed endpoint. Dispatch is post-persist — a delivery failure can never roll back the transition that landed — and a failed delivery is recorded for retry from the console rather than surfaced to the request that triggered it. **Changed:** *Console nav gains Webhooks* — The signed-in admin nav now includes Webhooks, shown when the webhooks primitive is wired. Endpoint create, list, enable / disable, delete, the delivery feed, and retry content-negotiate like the other console 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. An https-only URL or empty event set re-renders the form with the validator's message, and an unknown endpoint or delivery is a no-op notice rather than a 500.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
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; **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
|
|
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; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. The Returns, Reviews, Subscriptions, and Webhooks 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, subscriptions: !!deps.subscriptions };
|
|
228
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks };
|
|
229
229
|
|
|
230
230
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
231
231
|
|
|
@@ -1183,17 +1183,69 @@ function mount(router, deps) {
|
|
|
1183
1183
|
|
|
1184
1184
|
var webhooks = deps.webhooks || null;
|
|
1185
1185
|
if (webhooks) {
|
|
1186
|
-
|
|
1187
|
-
var body = req.body || {};
|
|
1188
|
-
var ep = await webhooks.endpoints.create({ url: body.url, events: body.events });
|
|
1189
|
-
_json(res, 201, ep);
|
|
1190
|
-
return ep;
|
|
1191
|
-
}));
|
|
1186
|
+
var KNOWN_WH_EVENTS = webhooks.KNOWN_EVENTS || [];
|
|
1192
1187
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1188
|
+
// Create content-negotiates: bearer → JSON (unchanged for tooling);
|
|
1189
|
+
// signed-in browser form → create, then a one-time secret reveal page.
|
|
1190
|
+
// The HMAC signing secret is shown once here and never rendered in the
|
|
1191
|
+
// list (endpoints.list returns it, so the list render omits it), the
|
|
1192
|
+
// way Stripe / GitHub surface webhook secrets.
|
|
1193
|
+
router.post("/admin/webhooks", _pageOrApi(false,
|
|
1194
|
+
W("webhook.create", async function (req, res) {
|
|
1195
|
+
var body = req.body || {};
|
|
1196
|
+
var ep = await webhooks.endpoints.create({ url: body.url, events: body.events });
|
|
1197
|
+
_json(res, 201, ep);
|
|
1198
|
+
return ep;
|
|
1199
|
+
}),
|
|
1200
|
+
async function (req, res) {
|
|
1201
|
+
var body = req.body || {};
|
|
1202
|
+
var events;
|
|
1203
|
+
if (body.events_all === "on" || body.events_all === "1") {
|
|
1204
|
+
events = "*";
|
|
1205
|
+
} else {
|
|
1206
|
+
events = KNOWN_WH_EVENTS.filter(function (ev) {
|
|
1207
|
+
var v = body["evt_" + ev];
|
|
1208
|
+
return v === "on" || v === "1";
|
|
1209
|
+
}).join(",");
|
|
1210
|
+
}
|
|
1211
|
+
var ep;
|
|
1212
|
+
try {
|
|
1213
|
+
ep = await webhooks.endpoints.create({ url: (typeof body.url === "string" ? body.url.trim() : body.url), events: events });
|
|
1214
|
+
} catch (e) {
|
|
1215
|
+
if (!(e instanceof TypeError)) throw e;
|
|
1216
|
+
var rows = await webhooks.endpoints.list();
|
|
1217
|
+
return _sendHtml(res, 400, renderAdminWebhooks({
|
|
1218
|
+
shop_name: deps.shop_name, nav_available: navAvailable, endpoints: rows,
|
|
1219
|
+
known_events: KNOWN_WH_EVENTS, notice: e.message.replace(/^webhooks:\s*/, ""),
|
|
1220
|
+
}));
|
|
1221
|
+
}
|
|
1222
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.create", outcome: "success", metadata: { id: ep.id } });
|
|
1223
|
+
// Direct 200, not a redirect — the one-time secret must never land
|
|
1224
|
+
// in a URL / server log / browser history.
|
|
1225
|
+
_sendHtml(res, 200, renderAdminWebhookSecret({
|
|
1226
|
+
shop_name: deps.shop_name, nav_available: navAvailable, endpoint: ep,
|
|
1227
|
+
}));
|
|
1228
|
+
},
|
|
1229
|
+
));
|
|
1230
|
+
|
|
1231
|
+
router.get("/admin/webhooks", _pageOrApi(true,
|
|
1232
|
+
R(async function (_req, res) {
|
|
1233
|
+
var rows = await webhooks.endpoints.list();
|
|
1234
|
+
_json(res, 200, { rows: rows });
|
|
1235
|
+
}),
|
|
1236
|
+
async function (req, res) {
|
|
1237
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1238
|
+
var rows = await webhooks.endpoints.list();
|
|
1239
|
+
_sendHtml(res, 200, renderAdminWebhooks({
|
|
1240
|
+
shop_name: deps.shop_name, nav_available: navAvailable, endpoints: rows,
|
|
1241
|
+
known_events: KNOWN_WH_EVENTS,
|
|
1242
|
+
created: url && url.searchParams.get("created"),
|
|
1243
|
+
toggled: url && url.searchParams.get("toggled"),
|
|
1244
|
+
deleted: url && url.searchParams.get("deleted"),
|
|
1245
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the endpoint." : null,
|
|
1246
|
+
}));
|
|
1247
|
+
},
|
|
1248
|
+
));
|
|
1197
1249
|
|
|
1198
1250
|
router.patch("/admin/webhooks/:id", W("webhook.update", async function (req, res) {
|
|
1199
1251
|
var ep = await webhooks.endpoints.update(req.params.id, req.body || {});
|
|
@@ -1209,20 +1261,93 @@ function mount(router, deps) {
|
|
|
1209
1261
|
return { id: req.params.id };
|
|
1210
1262
|
}));
|
|
1211
1263
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1264
|
+
// Browser-form equivalents of PATCH active / DELETE (HTML forms can
|
|
1265
|
+
// only GET/POST). Bearer clients keep using PATCH / DELETE above.
|
|
1266
|
+
router.post("/admin/webhooks/:id/toggle", _pageOrApi(false,
|
|
1267
|
+
W("webhook.update", async function (req, res) {
|
|
1268
|
+
var cur = await webhooks.endpoints.get(req.params.id);
|
|
1269
|
+
if (!cur) return _problem(res, 404, "webhook-not-found");
|
|
1270
|
+
var ep = await webhooks.endpoints.update(req.params.id, { active: cur.active ? false : true });
|
|
1271
|
+
_json(res, 200, ep);
|
|
1272
|
+
return ep;
|
|
1273
|
+
}),
|
|
1274
|
+
async function (req, res) {
|
|
1275
|
+
var ep = null;
|
|
1276
|
+
try {
|
|
1277
|
+
var cur = await webhooks.endpoints.get(req.params.id);
|
|
1278
|
+
if (cur) ep = await webhooks.endpoints.update(req.params.id, { active: cur.active ? false : true });
|
|
1279
|
+
} catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
1280
|
+
if (!ep) return _redirect(res, "/admin/webhooks?err=1");
|
|
1281
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.update", outcome: "success", metadata: { id: req.params.id } });
|
|
1282
|
+
_redirect(res, "/admin/webhooks?toggled=1");
|
|
1283
|
+
},
|
|
1284
|
+
));
|
|
1219
1285
|
|
|
1220
|
-
router.post("/admin/webhooks
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1286
|
+
router.post("/admin/webhooks/:id/delete", _pageOrApi(false,
|
|
1287
|
+
W("webhook.delete", async function (req, res) {
|
|
1288
|
+
var ok = await webhooks.endpoints.delete(req.params.id);
|
|
1289
|
+
if (!ok) return _problem(res, 404, "webhook-not-found");
|
|
1290
|
+
_json(res, 200, { ok: true });
|
|
1291
|
+
return { id: req.params.id };
|
|
1292
|
+
}),
|
|
1293
|
+
async function (req, res) {
|
|
1294
|
+
var ok = false;
|
|
1295
|
+
try { ok = await webhooks.endpoints.delete(req.params.id); }
|
|
1296
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
1297
|
+
if (!ok) return _redirect(res, "/admin/webhooks?err=1");
|
|
1298
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.delete", outcome: "success", metadata: { id: req.params.id } });
|
|
1299
|
+
_redirect(res, "/admin/webhooks?deleted=1");
|
|
1300
|
+
},
|
|
1301
|
+
));
|
|
1302
|
+
|
|
1303
|
+
router.get("/admin/webhooks/:id/deliveries", _pageOrApi(true,
|
|
1304
|
+
R(async function (req, res) {
|
|
1305
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1306
|
+
var limitS = url && url.searchParams.get("limit");
|
|
1307
|
+
var limit = limitS == null ? 50 : parseInt(limitS, 10);
|
|
1308
|
+
var rows = await webhooks.deliveries.list(req.params.id, { limit: limit });
|
|
1309
|
+
_json(res, 200, { rows: rows });
|
|
1310
|
+
}),
|
|
1311
|
+
async function (req, res) {
|
|
1312
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1313
|
+
var ep, rows;
|
|
1314
|
+
try {
|
|
1315
|
+
ep = await webhooks.endpoints.get(req.params.id);
|
|
1316
|
+
rows = ep ? await webhooks.deliveries.list(req.params.id, { limit: 100 }) : [];
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
if (!(e instanceof TypeError)) throw e;
|
|
1319
|
+
ep = null; rows = [];
|
|
1320
|
+
}
|
|
1321
|
+
if (!ep) return _sendHtml(res, 404, renderAdminWebhookDeliveries({
|
|
1322
|
+
shop_name: deps.shop_name, nav_available: navAvailable, endpoint: null, deliveries: [],
|
|
1323
|
+
}));
|
|
1324
|
+
_sendHtml(res, 200, renderAdminWebhookDeliveries({
|
|
1325
|
+
shop_name: deps.shop_name, nav_available: navAvailable, endpoint: ep, deliveries: rows,
|
|
1326
|
+
retried: url && url.searchParams.get("retried"),
|
|
1327
|
+
notice: (url && url.searchParams.get("err")) ? "That delivery couldn't be retried." : null,
|
|
1328
|
+
}));
|
|
1329
|
+
},
|
|
1330
|
+
));
|
|
1331
|
+
|
|
1332
|
+
// Retry composes the network transport (re-POSTs to the endpoint), so
|
|
1333
|
+
// a bearer client gets the JSON contract; a browser form retries then
|
|
1334
|
+
// PRGs back to the endpoint's delivery feed.
|
|
1335
|
+
router.post("/admin/webhooks/deliveries/:id/retry", _pageOrApi(false,
|
|
1336
|
+
W("webhook.retry", async function (req, res) {
|
|
1337
|
+
var d = await webhooks.deliveries.retry(req.params.id);
|
|
1338
|
+
if (!d) return _problem(res, 404, "delivery-not-found");
|
|
1339
|
+
_json(res, 200, d);
|
|
1340
|
+
return { id: req.params.id };
|
|
1341
|
+
}),
|
|
1342
|
+
async function (req, res) {
|
|
1343
|
+
var d = null;
|
|
1344
|
+
try { d = await webhooks.deliveries.retry(req.params.id); }
|
|
1345
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
1346
|
+
if (!d) return _redirect(res, "/admin/webhooks?err=1");
|
|
1347
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.retry", outcome: "success", metadata: { id: req.params.id } });
|
|
1348
|
+
_redirect(res, "/admin/webhooks/" + encodeURIComponent(d.endpoint_id) + "/deliveries?retried=1");
|
|
1349
|
+
},
|
|
1350
|
+
));
|
|
1226
1351
|
}
|
|
1227
1352
|
|
|
1228
1353
|
// ---- analytics ------------------------------------------------------
|
|
@@ -1821,6 +1946,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
1821
1946
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
1822
1947
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
1823
1948
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
1949
|
+
{ key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
|
|
1824
1950
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
1825
1951
|
{ key: "setup", href: "/admin/setup", label: "Setup" },
|
|
1826
1952
|
];
|
|
@@ -2434,6 +2560,118 @@ function renderAdminSubscriptionPlans(opts) {
|
|
|
2434
2560
|
return _renderAdminShell(opts.shop_name, "Subscription plans", bodyHtml, "subscriptions", opts.nav_available);
|
|
2435
2561
|
}
|
|
2436
2562
|
|
|
2563
|
+
function renderAdminWebhooks(opts) {
|
|
2564
|
+
opts = opts || {};
|
|
2565
|
+
var rows = opts.endpoints || [];
|
|
2566
|
+
var known = opts.known_events || [];
|
|
2567
|
+
var toggled = opts.toggled ? "<div class=\"banner banner--ok\">Endpoint updated.</div>" : "";
|
|
2568
|
+
var deleted = opts.deleted ? "<div class=\"banner banner--ok\">Endpoint deleted.</div>" : "";
|
|
2569
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
2570
|
+
|
|
2571
|
+
// The signing secret is intentionally absent from this table — it is
|
|
2572
|
+
// shown once on create (renderAdminWebhookSecret) and never again.
|
|
2573
|
+
var bodyRows = rows.map(function (e) {
|
|
2574
|
+
var isActive = e.active === 1 || e.active === true;
|
|
2575
|
+
var events = e.events === "*" ? "all events" : String(e.events || "").split(",").join(", ");
|
|
2576
|
+
return "<tr>" +
|
|
2577
|
+
"<td><code class=\"order-id\">" + _htmlEscape(e.url) + "</code></td>" +
|
|
2578
|
+
"<td>" + _htmlEscape(events) + "</td>" +
|
|
2579
|
+
"<td><span class=\"status-pill " + (isActive ? "paid" : "cancelled") + "\">" + (isActive ? "active" : "disabled") + "</span></td>" +
|
|
2580
|
+
"<td class=\"num\">" + _htmlEscape(String(e.rate_limit_per_minute)) + "/min</td>" +
|
|
2581
|
+
"<td>" +
|
|
2582
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/webhooks/" + _htmlEscape(e.id) + "/deliveries\">Deliveries</a> " +
|
|
2583
|
+
"<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
|
|
2584
|
+
"<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" style=\"display:inline;\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
|
|
2585
|
+
"</td></tr>";
|
|
2586
|
+
}).join("");
|
|
2587
|
+
|
|
2588
|
+
var table = rows.length
|
|
2589
|
+
? "<div class=\"panel\"><table><thead><tr><th>URL</th><th>Events</th><th>Status</th><th class=\"num\">Rate</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
2590
|
+
: "<p class=\"empty\">No webhook endpoints yet.</p>";
|
|
2591
|
+
|
|
2592
|
+
var eventChecks = known.map(function (ev) {
|
|
2593
|
+
return "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
|
|
2594
|
+
}).join("");
|
|
2595
|
+
|
|
2596
|
+
var createForm =
|
|
2597
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem; max-width:40rem;\">" +
|
|
2598
|
+
"<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Add an endpoint</h3>" +
|
|
2599
|
+
"<p class=\"meta\">Deliveries are signed (HMAC-SHA3-512); the signing secret is shown once, right after you create the endpoint. Only https:// URLs are accepted.</p>" +
|
|
2600
|
+
"<form method=\"post\" action=\"/admin/webhooks\">" +
|
|
2601
|
+
_setupField("Endpoint URL", "url", "", "url", "Where deliveries are POSTed (https:// only).", " maxlength=\"2048\" required") +
|
|
2602
|
+
"<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
|
|
2603
|
+
"<legend style=\"padding:0 .4rem; font-size:.85rem;\">Events</legend>" +
|
|
2604
|
+
"<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
|
|
2605
|
+
eventChecks +
|
|
2606
|
+
"<small style=\"color:var(--mute);\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
|
|
2607
|
+
"</fieldset>" +
|
|
2608
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create endpoint</button></div>" +
|
|
2609
|
+
"</form>" +
|
|
2610
|
+
"</div>";
|
|
2611
|
+
|
|
2612
|
+
var body = "<section><h2>Webhooks</h2>" + toggled + deleted + notice + table + createForm + "</section>";
|
|
2613
|
+
return _renderAdminShell(opts.shop_name, "Webhooks", body, "webhooks", opts.nav_available);
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function renderAdminWebhookSecret(opts) {
|
|
2617
|
+
opts = opts || {};
|
|
2618
|
+
var e = opts.endpoint || {};
|
|
2619
|
+
var body =
|
|
2620
|
+
"<section style=\"max-width:42rem;\">" +
|
|
2621
|
+
"<h2>Endpoint created</h2>" +
|
|
2622
|
+
"<div class=\"banner banner--ok\">Copy the signing secret now — it is shown once and cannot be retrieved again.</div>" +
|
|
2623
|
+
"<div class=\"panel\">" +
|
|
2624
|
+
"<p class=\"meta\">Endpoint</p><p><code class=\"order-id\">" + _htmlEscape(e.url || "") + "</code></p>" +
|
|
2625
|
+
"<p class=\"meta\">Signing secret (HMAC-SHA3-512, key id <code>v1</code>)</p>" +
|
|
2626
|
+
"<pre style=\"white-space:pre-wrap; word-break:break-all; background:var(--bg); border:1px solid var(--hair); border-radius:.5rem; padding:.75rem;\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
|
|
2627
|
+
"<p class=\"meta\">Verify each delivery's signature with this secret using your framework's webhook verifier.</p>" +
|
|
2628
|
+
"</div>" +
|
|
2629
|
+
"<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/webhooks\">Done</a></div>" +
|
|
2630
|
+
"</section>";
|
|
2631
|
+
return _renderAdminShell(opts.shop_name, "Endpoint created", body, "webhooks", opts.nav_available);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
function renderAdminWebhookDeliveries(opts) {
|
|
2635
|
+
opts = opts || {};
|
|
2636
|
+
var e = opts.endpoint;
|
|
2637
|
+
if (!e) {
|
|
2638
|
+
var nf = "<section><h2>Deliveries</h2><p class=\"empty\">Endpoint not found.</p>" +
|
|
2639
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/webhooks\">Back to webhooks</a></div></section>";
|
|
2640
|
+
return _renderAdminShell(opts.shop_name, "Deliveries", nf, "webhooks", opts.nav_available);
|
|
2641
|
+
}
|
|
2642
|
+
var rows = opts.deliveries || [];
|
|
2643
|
+
var retried = opts.retried ? "<div class=\"banner banner--ok\">Delivery retried.</div>" : "";
|
|
2644
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
2645
|
+
|
|
2646
|
+
var bodyRows = rows.map(function (d) {
|
|
2647
|
+
var ok = d.delivered_at != null;
|
|
2648
|
+
var statusCell = ok
|
|
2649
|
+
? "<span class=\"status-pill paid\">delivered</span>"
|
|
2650
|
+
: "<span class=\"status-pill " + (d.last_error ? "refunded" : "pending") + "\">" + (d.last_error ? "failed" : "pending") + "</span>";
|
|
2651
|
+
var code = d.last_status != null ? _htmlEscape(String(d.last_status)) : "—";
|
|
2652
|
+
var retry = ok ? "<span class=\"meta\">—</span>"
|
|
2653
|
+
: "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
|
|
2654
|
+
return "<tr>" +
|
|
2655
|
+
"<td>" + _htmlEscape(d.event_type) + "</td>" +
|
|
2656
|
+
"<td>" + statusCell + "</td>" +
|
|
2657
|
+
"<td class=\"num\">" + code + "</td>" +
|
|
2658
|
+
"<td class=\"num\">" + _htmlEscape(String(d.attempts)) + "</td>" +
|
|
2659
|
+
"<td>" + (d.last_error ? "<span class=\"meta\">" + _htmlEscape(d.last_error) + "</span>" : "") + "</td>" +
|
|
2660
|
+
"<td>" + _htmlEscape(_fmtDate(d.created_at)) + "</td>" +
|
|
2661
|
+
"<td>" + retry + "</td>" +
|
|
2662
|
+
"</tr>";
|
|
2663
|
+
}).join("");
|
|
2664
|
+
|
|
2665
|
+
var table = rows.length
|
|
2666
|
+
? "<div class=\"panel\"><table><thead><tr><th>Event</th><th>Status</th><th class=\"num\">Code</th><th class=\"num\">Attempts</th><th>Last error</th><th>Created</th><th></th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
2667
|
+
: "<p class=\"empty\">No deliveries recorded for this endpoint yet.</p>";
|
|
2668
|
+
|
|
2669
|
+
var head = "<p class=\"meta\">Endpoint <code class=\"order-id\">" + _htmlEscape(e.url) + "</code></p>";
|
|
2670
|
+
var body = "<section><h2>Deliveries</h2>" + retried + notice + head + table +
|
|
2671
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/webhooks\">Back to webhooks</a></div></section>";
|
|
2672
|
+
return _renderAdminShell(opts.shop_name, "Deliveries", body, "webhooks", opts.nav_available);
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2437
2675
|
module.exports = {
|
|
2438
2676
|
mount: mount,
|
|
2439
2677
|
AUDIT_NAMESPACE: AUDIT_NAMESPACE,
|
package/lib/order.js
CHANGED
|
@@ -280,25 +280,31 @@ function create(opts) {
|
|
|
280
280
|
],
|
|
281
281
|
);
|
|
282
282
|
var refreshed = await this.get(orderId);
|
|
283
|
-
// Fan-out to merchant webhook subscribers. The
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
283
|
+
// Fan-out to merchant webhook subscribers is fire-and-forget. The
|
|
284
|
+
// transition has already persisted; the request must not wait on
|
|
285
|
+
// outbound HTTP, or a slow / unreachable endpoint would block the
|
|
286
|
+
// transition for seconds (the HTTP client's idle timeout plus
|
|
287
|
+
// retry). send() persists a delivery row per subscriber and attempts
|
|
288
|
+
// delivery in the background; a failure lives in
|
|
289
|
+
// `webhook_deliveries.last_error` for retry from the admin console.
|
|
290
|
+
// The promise is detached with a swallowing .catch so a rejection —
|
|
291
|
+
// or a synchronous throw from send() — never becomes an
|
|
292
|
+
// unhandledRejection and never touches the transition's latency.
|
|
288
293
|
if (webhooks && typeof webhooks.send === "function") {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
294
|
+
var _whPayload = {
|
|
295
|
+
order: refreshed,
|
|
296
|
+
transition: {
|
|
297
|
+
from: result.from,
|
|
298
|
+
to: result.to,
|
|
299
|
+
on_event: event,
|
|
300
|
+
reason: (opts2 && opts2.reason) || null,
|
|
301
|
+
metadata: (opts2 && opts2.metadata) || {},
|
|
302
|
+
occurred_at: ts,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
Promise.resolve().then(function () {
|
|
306
|
+
return webhooks.send("order." + event, _whPayload);
|
|
307
|
+
}).catch(function () { /* drop-silent — delivery rows hold the failure */ });
|
|
302
308
|
}
|
|
303
309
|
return refreshed;
|
|
304
310
|
},
|
package/package.json
CHANGED