@blamejs/blamejs-shop 0.3.65 → 0.3.66
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/lib/admin.js +128 -2
- package/lib/asset-manifest.json +1 -1
- package/lib/error-log.js +190 -0
- package/lib/storefront.js +17 -0
- 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.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.66 (2026-06-05) — **Production server errors are readable from the admin console and the CLI.** When a server-side failure was scrubbed to a clean customer-facing page — a payment-processor rejection at checkout, a public-API failure, an admin-action error — the underlying detail was only visible in the hosting dashboard's container log viewer. The error-log primitive now captures those failures into the database: route, method, status, a length-bounded message, and a timestamp, ring-buffered to the newest five hundred entries, written fire-and-forget so a logging failure can never affect a customer request, and carrying no customer or session data. A new Errors screen in the admin console lists them newest-first, and the same route answers a bearer-token request with JSON, so operators and tooling can read production errors with one curl. A schema migration adds the message column and applies on deploy. **Added:** *Server-error capture with an admin Errors screen and JSON API* — Scrubbed 500-class failures — the checkout confirm step, the public catalog API, and admin console actions — now also record route, method, status, and a truncated error message into the error-log table (newest five hundred kept; writes are drop-silent and never awaited in the request path; no customer, session, or header data is stored). GET /admin/errors renders the newest entries in the console with every cell escaped, and the same path with an Authorization: Bearer admin token returns JSON for command-line and tooling access. Migration 0208 adds the message column plus an index and applies with the normal deploy.
|
|
12
|
+
|
|
11
13
|
- v0.3.65 (2026-06-04) — **Operator-tuned search ranking applies to edge-served visitors.** Search-ranking weight sets and manual pins applied only on container-served search — anonymous visitors, who are served from the edge cache and make up most traffic, got unranked results no matter what the operator tuned. The edge search path now applies the identical ranking: pinned products lead in their configured positions, the rest order by the active weight set, with byte-identical scoring pinned by a parity test in the same style as the existing faceting and synonym parity guards. Ranking and pin changes reach edge visitors within the same sixty-second cache window as every other operator-tunable edge surface. If the ranking tables are absent or unconfigured, edge search degrades to its previous unranked order rather than failing. **Fixed:** *Edge search results honor ranking weights and manual pins* — The /search route served from the edge ordered results by filter match alone, ignoring the active search weight set and any merchandiser pins, so ranking tuning was visible only to signed-in (container-served) visitors. The edge now loads the active weight set and pins on each cache fill and applies the same scoring as the container — pins first in position order, then weighted score descending, with identical tie-breaking. The two implementations are locked together by a parity test that runs the same fixtures through both. Changes the operator makes take effect for edge visitors within the standard sixty-second cache freshness window; a missing or unconfigured ranking table degrades to the unranked order, never an error.
|
|
12
14
|
|
|
13
15
|
- v0.3.64 (2026-06-04) — **Google Pay and Apple Pay express buttons can run on the payment page.** The app-level Permissions-Policy denies the Payment Request API everywhere — including the payment page, the one surface whose job is payment — so Stripe's express wallet buttons (Google Pay, Apple Pay) could not operate and the browser console filled with policy violations. The payment page now sends a route-scoped Permissions-Policy that grants the payment feature to the page itself, Stripe, and Google Pay, while every other feature stays denied and every other route keeps the fully strict header — the same route-scoping pattern the payment pages already use for their Content-Security-Policy. Card payments and 3-D Secure challenges were verified live before and after; a remaining cosmetic Trusted-Types console message from Stripe's loader is documented in code with the test recipe for future verification, and does not affect card, wallet, or 3-D Secure flows. **Fixed:** *Payment page grants the Payment Request API to Stripe and Google Pay* — The default Permissions-Policy sets payment=() sitewide, which blocks the Payment Request API that Stripe's express wallet buttons need. The /pay route now emits the same policy with exactly one change — payment=(self "https://js.stripe.com" "https://pay.google.com") — derived from the same source as the default header so the two can never drift apart. Camera, microphone, geolocation, and every other feature remain denied on the payment page, and all other routes keep payment=(). Tests pin the scoped header on the payment route, the strict header elsewhere, and that exactly one feature differs from the default.
|
package/lib/admin.js
CHANGED
|
@@ -43,6 +43,15 @@ var b = require("./vendor/blamejs");
|
|
|
43
43
|
|
|
44
44
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
45
45
|
|
|
46
|
+
// Operator-readable error-log sink (lib/error-log.js), set by mount()
|
|
47
|
+
// when the deployment wires `deps.errorLog`. `_safeNotice` records the
|
|
48
|
+
// scrubbed message of a genuine 5xx here so the failure is reachable
|
|
49
|
+
// from /admin/errors + the admin JSON API, not just the framework
|
|
50
|
+
// audit sink. Module-level (like the `b.audit` _safeNotice already
|
|
51
|
+
// uses) because admin.mount runs once per process; null until wired,
|
|
52
|
+
// and every write is drop-silent / fire-and-forget.
|
|
53
|
+
var _errorLogSink = null;
|
|
54
|
+
|
|
46
55
|
// Per-request store for the double-submit CSRF token. The admin console is
|
|
47
56
|
// container-only and has no locale ALS (unlike the storefront), so it gets
|
|
48
57
|
// its own: a sync middleware in `mount()` seeds the request's `req.csrfToken`
|
|
@@ -444,11 +453,26 @@ function _safeNotice(e, auditAction) {
|
|
|
444
453
|
// Genuine unknown error — record it server-side via the framework audit
|
|
445
454
|
// (drop-silent) so operators can correlate, then hand back a generic
|
|
446
455
|
// message + a 500. The raw message never reaches the client.
|
|
456
|
+
var detail = msg || String(e);
|
|
447
457
|
b.audit.safeEmit({
|
|
448
458
|
action: AUDIT_NAMESPACE + "." + (auditAction || "request") + ".error",
|
|
449
459
|
outcome: "failure",
|
|
450
|
-
metadata: { message:
|
|
460
|
+
metadata: { message: detail },
|
|
451
461
|
});
|
|
462
|
+
// ALSO record into the operator-readable error log when wired, so an
|
|
463
|
+
// admin-console 500 surfaces at /admin/errors + the admin JSON API.
|
|
464
|
+
// The route is the audit action namespace (the handler path isn't
|
|
465
|
+
// threaded here); drop-silent + fire-and-forget, never blocks the
|
|
466
|
+
// response. captureServerError can't throw, but guard the dispatch.
|
|
467
|
+
if (_errorLogSink) {
|
|
468
|
+
try {
|
|
469
|
+
_errorLogSink.captureServerError({
|
|
470
|
+
route: "admin:" + (auditAction || "request"),
|
|
471
|
+
message: detail,
|
|
472
|
+
status: 500,
|
|
473
|
+
});
|
|
474
|
+
} catch (_capErr) { /* drop-silent — never let recording crash the catch */ }
|
|
475
|
+
}
|
|
452
476
|
return { status: 500, code: "internal-error", message: "Something went wrong — please try again." };
|
|
453
477
|
}
|
|
454
478
|
|
|
@@ -536,6 +560,12 @@ function mount(router, deps) {
|
|
|
536
560
|
// An operator who wants the console surface minimized can pass
|
|
537
561
|
// `auditLog: false` to hide both the route and the nav link.
|
|
538
562
|
var auditLog = deps.auditLog !== false;
|
|
563
|
+
// Operator-readable error feed at /admin/errors. The route + nav link
|
|
564
|
+
// mount only when the deployment wires `deps.errorLog` (the
|
|
565
|
+
// lib/error-log.js handle backed by D1). Also published to the
|
|
566
|
+
// module-level sink so `_safeNotice` records admin-console 500s here.
|
|
567
|
+
var errorLog = deps.errorLog || null;
|
|
568
|
+
_errorLogSink = errorLog;
|
|
539
569
|
|
|
540
570
|
// Which optional console sections are wired — gates their nav links so a
|
|
541
571
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
@@ -543,7 +573,7 @@ function mount(router, deps) {
|
|
|
543
573
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
544
574
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
545
575
|
// notice when the salesReports primitive isn't wired.
|
|
546
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog };
|
|
576
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
|
|
547
577
|
|
|
548
578
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
549
579
|
|
|
@@ -1838,6 +1868,55 @@ function mount(router, deps) {
|
|
|
1838
1868
|
));
|
|
1839
1869
|
}
|
|
1840
1870
|
|
|
1871
|
+
// ---- captured server errors (operator-readable error feed) ----------
|
|
1872
|
+
//
|
|
1873
|
+
// Surfaces the scrubbed messages of server-side 5xx-class failures
|
|
1874
|
+
// (checkout-confirm 500, the catalog-API scrub, the admin
|
|
1875
|
+
// unknown-error path) that lib/error-log.js captured into D1. The
|
|
1876
|
+
// problem this solves: those messages otherwise reach only the
|
|
1877
|
+
// container's LOCAL audit sink, which `wrangler tail` doesn't carry —
|
|
1878
|
+
// so an operator (or tooling) had no CLI/HTTP way to read production
|
|
1879
|
+
// errors. Both surfaces share one read:
|
|
1880
|
+
//
|
|
1881
|
+
// * `GET /admin/errors` with a bearer token → JSON
|
|
1882
|
+
// ({ rows, limit }) for CLI/tooling:
|
|
1883
|
+
// curl -H "Authorization: Bearer $ADMIN_API_KEY" https://…/admin/errors
|
|
1884
|
+
// * `GET /admin/errors` with the admin browser cookie → the HTML
|
|
1885
|
+
// console screen (newest-first, time + route + message, monospace,
|
|
1886
|
+
// escaped).
|
|
1887
|
+
//
|
|
1888
|
+
// Read-only observability view: any read failure degrades to an empty
|
|
1889
|
+
// table + notice, never a 500.
|
|
1890
|
+
if (errorLog) {
|
|
1891
|
+
function _errorsLimit(req) {
|
|
1892
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1893
|
+
var n = parseInt((url && url.searchParams.get("limit")) || "", 10);
|
|
1894
|
+
// Clamp into the primitive's [1, 500] read bound; default 100.
|
|
1895
|
+
if (!Number.isFinite(n) || n < 1) return 100;
|
|
1896
|
+
return n > 500 ? 500 : n;
|
|
1897
|
+
}
|
|
1898
|
+
router.get("/admin/errors", _pageOrApi(true,
|
|
1899
|
+
R(async function (req, res) { // bearer → JSON
|
|
1900
|
+
var limit = _errorsLimit(req);
|
|
1901
|
+
var rows = [];
|
|
1902
|
+
try { rows = await errorLog.recentServerErrors({ limit: limit }); }
|
|
1903
|
+
catch (_e) { rows = []; }
|
|
1904
|
+
_json(res, 200, { rows: rows, limit: limit });
|
|
1905
|
+
}),
|
|
1906
|
+
async function (req, res) { // browser cookie → HTML
|
|
1907
|
+
var limit = _errorsLimit(req);
|
|
1908
|
+
var rows = [];
|
|
1909
|
+
var notice = null;
|
|
1910
|
+
try { rows = await errorLog.recentServerErrors({ limit: limit }); }
|
|
1911
|
+
catch (_e) { rows = []; notice = "The error log is temporarily unavailable."; }
|
|
1912
|
+
_sendHtml(res, 200, renderAdminErrors({
|
|
1913
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1914
|
+
rows: rows, notice: notice,
|
|
1915
|
+
}));
|
|
1916
|
+
},
|
|
1917
|
+
));
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1841
1920
|
// ---- shipment tracking (operator-managed) ---------------------------
|
|
1842
1921
|
//
|
|
1843
1922
|
// Attach a shipment to an order (carrier + optional tracking number) and
|
|
@@ -10605,6 +10684,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
10605
10684
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
10606
10685
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
10607
10686
|
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
10687
|
+
{ key: "errors", href: "/admin/errors", label: "Errors", requires: "errorLog" },
|
|
10608
10688
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
10609
10689
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
10610
10690
|
{ key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
|
|
@@ -11005,6 +11085,52 @@ function renderAdminAudit(opts) {
|
|
|
11005
11085
|
return _renderAdminShell(opts.shop_name, "Audit", body, "audit", opts.nav_available);
|
|
11006
11086
|
}
|
|
11007
11087
|
|
|
11088
|
+
// epoch-ms → "YYYY-MM-DD HH:MM:SS" (UTC). The error feed needs
|
|
11089
|
+
// to-the-second precision (multiple 500s a minute aren't unusual), so
|
|
11090
|
+
// this is finer-grained than `_fmtDate`. Guards a bad value to an em
|
|
11091
|
+
// dash so the template never throws.
|
|
11092
|
+
function _fmtTs(v) {
|
|
11093
|
+
var n = typeof v === "number" ? v : Date.parse(v);
|
|
11094
|
+
if (!isFinite(n)) return "—";
|
|
11095
|
+
return new Date(n).toISOString().slice(0, 19).replace("T", " ") + " UTC";
|
|
11096
|
+
}
|
|
11097
|
+
|
|
11098
|
+
// Captured-server-error feed — newest-first list of the scrubbed 5xx
|
|
11099
|
+
// messages lib/error-log.js recorded, so an operator reads production
|
|
11100
|
+
// failures from the console (and, via the bearer JSON contract, the
|
|
11101
|
+
// CLI) instead of only the container's local logs. Every cell is
|
|
11102
|
+
// escaped at the builder: `route` and `message` are operator/system
|
|
11103
|
+
// strings that could in principle carry markup, so a captured payload
|
|
11104
|
+
// is neutralized before it reaches the DOM (the message column escapes
|
|
11105
|
+
// THEN renders inside <code> — no truncation, the whole captured
|
|
11106
|
+
// message is the point of the feed).
|
|
11107
|
+
function renderAdminErrors(opts) {
|
|
11108
|
+
opts = opts || {};
|
|
11109
|
+
var rows = opts.rows || [];
|
|
11110
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11111
|
+
|
|
11112
|
+
var bodyRows = rows.map(function (row) {
|
|
11113
|
+
return "<tr>" +
|
|
11114
|
+
"<td>" + _htmlEscape(_fmtTs(row.occurred_at)) + "</td>" +
|
|
11115
|
+
"<td><code>" + _htmlEscape(String(row.status)) + "</code></td>" +
|
|
11116
|
+
"<td><code>" + _htmlEscape(String(row.method || "")) + "</code></td>" +
|
|
11117
|
+
"<td><code>" + _htmlEscape(String(row.route || "")) + "</code></td>" +
|
|
11118
|
+
"<td class=\"audit-meta\"><code>" + _htmlEscape(String(row.message == null ? "" : row.message)) + "</code></td>" +
|
|
11119
|
+
"</tr>";
|
|
11120
|
+
}).join("");
|
|
11121
|
+
|
|
11122
|
+
var table = rows.length
|
|
11123
|
+
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Time</th><th scope=\"col\">Status</th><th scope=\"col\">Method</th><th scope=\"col\">Route</th><th scope=\"col\">Message</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
|
|
11124
|
+
: "<p class=\"empty\">No server errors recorded.</p>";
|
|
11125
|
+
|
|
11126
|
+
var lede = "<p class=\"meta\">Server-side 5xx failures captured from the request lifecycle — newest first. " +
|
|
11127
|
+
"The same feed is available as JSON for tooling: " +
|
|
11128
|
+
"<code>GET /admin/errors</code> with an <code>Authorization: Bearer</code> token.</p>";
|
|
11129
|
+
|
|
11130
|
+
var body = "<section><h2>Errors</h2>" + notice + lede + table + "</section>";
|
|
11131
|
+
return _renderAdminShell(opts.shop_name, "Errors", body, "errors", opts.nav_available);
|
|
11132
|
+
}
|
|
11133
|
+
|
|
11008
11134
|
// epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
|
|
11009
11135
|
// guard so a bad value never throws inside the template.
|
|
11010
11136
|
function _dateInputValue(v) {
|
package/lib/asset-manifest.json
CHANGED
package/lib/error-log.js
CHANGED
|
@@ -58,6 +58,29 @@
|
|
|
58
58
|
* errorLog.metrics({ from, to })
|
|
59
59
|
* → { total, by_status_class, p50_ms, p95_ms, p99_ms }
|
|
60
60
|
*
|
|
61
|
+
* Captured-error surface (the operator-readable error feed):
|
|
62
|
+
*
|
|
63
|
+
* errorLog.captureServerError({ route, message, status?, method?, occurred_at? })
|
|
64
|
+
* → { id, occurred_at } on success
|
|
65
|
+
* → { dropped: true, reason } on bad input / write failure (no throw)
|
|
66
|
+
*
|
|
67
|
+
* errorLog.recentServerErrors({ limit? })
|
|
68
|
+
* → [{ id, status, route, method, message, occurred_at }]
|
|
69
|
+
* newest-first
|
|
70
|
+
*
|
|
71
|
+
* `captureServerError` records the scrubbed MESSAGE of a server-side
|
|
72
|
+
* 5xx-class failure (the detail a catch would otherwise lose to the
|
|
73
|
+
* container's local audit sink) into the `error_log` table's
|
|
74
|
+
* `message` column. It is drop-silent on bad input AND on write
|
|
75
|
+
* failure — wired into request catch blocks, it must never turn one
|
|
76
|
+
* failed request into two. The feed is a ring buffer: each capture
|
|
77
|
+
* trims to the newest SERVER_ERROR_CAP message-bearing rows. The
|
|
78
|
+
* message is length-bounded (truncated to MAX_MESSAGE, not dropped —
|
|
79
|
+
* the head of a failure is the useful part). `recentServerErrors`
|
|
80
|
+
* (read-side, THROWS on bad input) backs the admin console screen +
|
|
81
|
+
* the admin JSON API so operators read production errors from the
|
|
82
|
+
* CLI.
|
|
83
|
+
*
|
|
61
84
|
* Percentile math: response_time_ms is collected, sorted, and a
|
|
62
85
|
* simple index-into-sorted-array is read off (no streaming
|
|
63
86
|
* estimator — operators on a single-shard D1 dataset hit modest
|
|
@@ -87,6 +110,19 @@ var MAX_SESSION_ID = 512;
|
|
|
87
110
|
var MAX_CUSTOMER_ID = 512;
|
|
88
111
|
var MAX_ERROR_ID = 128;
|
|
89
112
|
|
|
113
|
+
// Captured server-error message bound. Unlike `path` (where an
|
|
114
|
+
// over-long value is a caller bug and drops), the message is the whole
|
|
115
|
+
// reason the row exists — a long stack/PSP string is TRUNCATED to this
|
|
116
|
+
// bound rather than dropped, so the operator still sees the head of the
|
|
117
|
+
// failure. The tail is the least useful part of a scrubbed message.
|
|
118
|
+
var MAX_MESSAGE = 1024;
|
|
119
|
+
|
|
120
|
+
// Ring-buffer cap on the captured-server-error feed. Each capture
|
|
121
|
+
// trims rows beyond the newest N (message-bearing rows only — the
|
|
122
|
+
// high-volume response-metadata rows are untouched), so the feed is
|
|
123
|
+
// self-trimming and never grows unbounded on a single-shard D1.
|
|
124
|
+
var SERVER_ERROR_CAP = 500;
|
|
125
|
+
|
|
90
126
|
// ---- read-side validators (THROW — dashboard queries) ------------------
|
|
91
127
|
|
|
92
128
|
function _epochMs(n, label) {
|
|
@@ -252,6 +288,158 @@ function create(opts) {
|
|
|
252
288
|
}
|
|
253
289
|
},
|
|
254
290
|
|
|
291
|
+
// Capture a server-side 5xx-class failure's MESSAGE for the
|
|
292
|
+
// operator console + the admin JSON API. **Drop-silent on bad
|
|
293
|
+
// input AND on write failure — by design.** This is wired into the
|
|
294
|
+
// catch blocks of failing requests (checkout confirm, the public
|
|
295
|
+
// API 500 scrub, the admin unknown-error path); an error-log write
|
|
296
|
+
// that threw would turn one failed request into two, so every
|
|
297
|
+
// refusal resolves to a silent drop.
|
|
298
|
+
//
|
|
299
|
+
// The captured row reuses the `error_log` table with the `message`
|
|
300
|
+
// column set — that's what distinguishes a captured-error row from
|
|
301
|
+
// a response-metadata row. Only the route + method + status +
|
|
302
|
+
// message + timestamp are stored; NO session/customer/referrer/UA
|
|
303
|
+
// PII rides this path (the message is operator-supplied and already
|
|
304
|
+
// scrubbed by the caller — this primitive bounds its length, it
|
|
305
|
+
// does not re-scrub content).
|
|
306
|
+
//
|
|
307
|
+
// captureServerError({
|
|
308
|
+
// route, // required — request path, query stripped
|
|
309
|
+
// message, // required — scrubbed error text (truncated to MAX_MESSAGE)
|
|
310
|
+
// status?, // optional — defaults to 500; clamped to [500, 599]
|
|
311
|
+
// method?, // optional — defaults to "GET"; must be a known method
|
|
312
|
+
// occurred_at?, // optional — defaults to Date.now()
|
|
313
|
+
// })
|
|
314
|
+
// → { id, occurred_at } on success
|
|
315
|
+
// → { dropped: true, reason } on bad input / write failure (no throw)
|
|
316
|
+
//
|
|
317
|
+
// After a successful insert the feed is trimmed to the newest
|
|
318
|
+
// SERVER_ERROR_CAP message-bearing rows (the ring buffer). The trim
|
|
319
|
+
// is best-effort: a trim failure never propagates (the row already
|
|
320
|
+
// landed; an over-cap feed self-corrects on the next capture).
|
|
321
|
+
captureServerError: async function (input) {
|
|
322
|
+
try {
|
|
323
|
+
if (!input || typeof input !== "object") {
|
|
324
|
+
return { dropped: true, reason: "input must be an object" };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// route — required, bounded string (query string stripped by
|
|
328
|
+
// the caller, same contract as recordError's `path`).
|
|
329
|
+
var route = input.route;
|
|
330
|
+
if (!_isBoundedString(route, MAX_PATH)) {
|
|
331
|
+
return { dropped: true, reason: "route must be a non-empty string ≤ " + MAX_PATH + " chars" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// message — required, non-empty. Over-long is TRUNCATED (the
|
|
335
|
+
// head of a scrubbed failure is the useful part), not dropped.
|
|
336
|
+
var rawMessage = input.message;
|
|
337
|
+
if (typeof rawMessage !== "string" || rawMessage.length === 0) {
|
|
338
|
+
return { dropped: true, reason: "message must be a non-empty string" };
|
|
339
|
+
}
|
|
340
|
+
var message = rawMessage.length > MAX_MESSAGE
|
|
341
|
+
? rawMessage.slice(0, MAX_MESSAGE)
|
|
342
|
+
: rawMessage;
|
|
343
|
+
|
|
344
|
+
// status — optional, defaults to 500. Clamp into the 5xx band:
|
|
345
|
+
// this surface is for server-side failures, and a caller that
|
|
346
|
+
// hands a 4xx is misclassifying (the 4xx observability rows go
|
|
347
|
+
// through recordError, not here).
|
|
348
|
+
var status;
|
|
349
|
+
if (input.status == null) {
|
|
350
|
+
status = 500;
|
|
351
|
+
} else if (_isInt(input.status) && input.status >= 500 && input.status <= 599) {
|
|
352
|
+
status = input.status;
|
|
353
|
+
} else {
|
|
354
|
+
return { dropped: true, reason: "status must be an integer in [500, 599] when provided" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// method — optional, defaults to GET, must be a known method.
|
|
358
|
+
var method;
|
|
359
|
+
if (input.method == null) {
|
|
360
|
+
method = "GET";
|
|
361
|
+
} else if (typeof input.method === "string" && METHODS.indexOf(input.method) !== -1) {
|
|
362
|
+
method = input.method;
|
|
363
|
+
} else {
|
|
364
|
+
return { dropped: true, reason: "method must be one of " + METHODS.join(", ") + " when provided" };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// occurred_at — optional, defaults to Date.now().
|
|
368
|
+
var occurredAt;
|
|
369
|
+
if (input.occurred_at == null) {
|
|
370
|
+
occurredAt = Date.now();
|
|
371
|
+
} else if (_isNonNegative(input.occurred_at)) {
|
|
372
|
+
occurredAt = input.occurred_at;
|
|
373
|
+
} else {
|
|
374
|
+
return { dropped: true, reason: "occurred_at must be a non-negative integer when provided" };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// user_agent_class is NOT NULL on the table — captured errors
|
|
378
|
+
// carry no UA classification (the catch has no UA context worth
|
|
379
|
+
// persisting), so they land under the neutral "other" bucket.
|
|
380
|
+
var id = b.uuid.v7();
|
|
381
|
+
await query(
|
|
382
|
+
"INSERT INTO error_log " +
|
|
383
|
+
"(id, status, path, method, user_agent_class, message, occurred_at) " +
|
|
384
|
+
"VALUES (?1, ?2, ?3, ?4, 'other', ?5, ?6)",
|
|
385
|
+
[id, status, route, method, message, occurredAt],
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Ring-buffer trim — keep the newest SERVER_ERROR_CAP captured
|
|
389
|
+
// rows, delete the rest. Scoped to message-bearing rows so the
|
|
390
|
+
// response-metadata feed is never touched. Best-effort: a trim
|
|
391
|
+
// failure leaves an over-cap feed that the next capture corrects.
|
|
392
|
+
try {
|
|
393
|
+
await query(
|
|
394
|
+
"DELETE FROM error_log " +
|
|
395
|
+
" WHERE message IS NOT NULL " +
|
|
396
|
+
" AND id NOT IN ( " +
|
|
397
|
+
" SELECT id FROM error_log " +
|
|
398
|
+
" WHERE message IS NOT NULL " +
|
|
399
|
+
" ORDER BY occurred_at DESC, id DESC " +
|
|
400
|
+
" LIMIT ?1 " +
|
|
401
|
+
" )",
|
|
402
|
+
[SERVER_ERROR_CAP],
|
|
403
|
+
);
|
|
404
|
+
} catch (_trimErr) { /* best-effort — feed self-corrects next capture */ }
|
|
405
|
+
|
|
406
|
+
return { id: id, occurred_at: occurredAt };
|
|
407
|
+
} catch (_e) {
|
|
408
|
+
// Drop-silent on any unexpected failure — an error-log write
|
|
409
|
+
// mid-request must never crash the response it observes.
|
|
410
|
+
return { dropped: true, reason: "internal" };
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
// Newest-first page of captured server errors for the operator
|
|
415
|
+
// console + the admin JSON API. Read-side surface — THROWS on bad
|
|
416
|
+
// input (an operator's on the other end of the stack trace). Only
|
|
417
|
+
// the message-bearing rows are returned; the response-metadata rows
|
|
418
|
+
// recordError writes never surface here.
|
|
419
|
+
recentServerErrors: async function (opts) {
|
|
420
|
+
opts = opts || {};
|
|
421
|
+
var limit = opts.limit == null ? 100 : opts.limit;
|
|
422
|
+
_limit(limit, "limit", 500);
|
|
423
|
+
var r = await query(
|
|
424
|
+
"SELECT id, status, path, method, message, occurred_at " +
|
|
425
|
+
" FROM error_log " +
|
|
426
|
+
" WHERE message IS NOT NULL " +
|
|
427
|
+
" ORDER BY occurred_at DESC, id DESC " +
|
|
428
|
+
" LIMIT ?1",
|
|
429
|
+
[limit],
|
|
430
|
+
);
|
|
431
|
+
return r.rows.map(function (row) {
|
|
432
|
+
return {
|
|
433
|
+
id: row.id,
|
|
434
|
+
status: Number(row.status) || 0,
|
|
435
|
+
route: row.path,
|
|
436
|
+
method: row.method,
|
|
437
|
+
message: row.message,
|
|
438
|
+
occurred_at: Number(row.occurred_at) || 0,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
|
|
255
443
|
// Top-N 404 paths within the window. GROUP BY path, ORDER BY
|
|
256
444
|
// count DESC. The aggregate strips query strings at the write
|
|
257
445
|
// site so a single dead link surfaces as one row regardless of
|
|
@@ -518,4 +706,6 @@ module.exports = {
|
|
|
518
706
|
ONE_YEAR_MS: ONE_YEAR_MS,
|
|
519
707
|
DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
|
|
520
708
|
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
709
|
+
MAX_MESSAGE: MAX_MESSAGE,
|
|
710
|
+
SERVER_ERROR_CAP: SERVER_ERROR_CAP,
|
|
521
711
|
};
|
package/lib/storefront.js
CHANGED
|
@@ -9775,6 +9775,15 @@ function mount(router, deps) {
|
|
|
9775
9775
|
// launch flow converts the reservation into a Stripe-gated order).
|
|
9776
9776
|
var preorder = deps.preorder || null;
|
|
9777
9777
|
|
|
9778
|
+
// Operator-readable error log (lib/error-log.js). When wired, the
|
|
9779
|
+
// server-side catches below ALSO record their scrubbed failure
|
|
9780
|
+
// message here so it's reachable from the admin console + JSON API,
|
|
9781
|
+
// not just the container-local audit sink. Optional — absent it, the
|
|
9782
|
+
// catches behave exactly as before (audit-emit only). Every call is
|
|
9783
|
+
// drop-silent + fire-and-forget: captureServerError can never throw,
|
|
9784
|
+
// and a failing response must not wait on (or be undone by) the write.
|
|
9785
|
+
var errorLog = deps.errorLog || null;
|
|
9786
|
+
|
|
9778
9787
|
// CAPTCHA gate (bot challenge at signup / login / checkout). Active ONLY
|
|
9779
9788
|
// when the operator has registered a provider AND set CAPTCHA_PROVIDER_SLUG
|
|
9780
9789
|
// (server.js resolves the provider row at boot into captchaKind +
|
|
@@ -12437,6 +12446,14 @@ function mount(router, deps) {
|
|
|
12437
12446
|
outcome: "failure",
|
|
12438
12447
|
metadata: { message: msg },
|
|
12439
12448
|
});
|
|
12449
|
+
// ALSO record into the operator-readable error log so this
|
|
12450
|
+
// 500's detail is reachable from /admin/errors + the admin
|
|
12451
|
+
// JSON API, not just the container-local audit sink. Drop-
|
|
12452
|
+
// silent + fire-and-forget — never blocks or breaks the
|
|
12453
|
+
// styled error page the customer gets.
|
|
12454
|
+
if (errorLog) {
|
|
12455
|
+
errorLog.captureServerError({ route: "/checkout", message: msg, method: "POST", status: 500 });
|
|
12456
|
+
}
|
|
12440
12457
|
}
|
|
12441
12458
|
return _send(res, clientErr ? 400 : 500, renderCheckoutError({
|
|
12442
12459
|
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
package/package.json
CHANGED