@blamejs/blamejs-shop 0.5.23 → 0.5.24
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/SECURITY.md +6 -1
- package/lib/admin.js +176 -19
- package/lib/asset-manifest.json +1 -1
- package/lib/business-hours.js +4 -5
- package/lib/carrier-rates.js +4 -5
- package/lib/customer-impersonation.js +35 -5
- package/lib/delivery-estimate.js +4 -5
- package/lib/payment.js +5 -3
- package/lib/shipping-labels.js +4 -5
- package/lib/shipping-zones.js +4 -5
- package/lib/storefront.js +14 -0
- package/lib/webhooks.js +5 -3
- 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.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.24 (2026-08-11) — **A supervisor can see every operator currently inside a customer's account and end any of them, and admin responses are no longer served to a shared cache without instruction.** The support session added in the previous release could be started, watched by the customer it concerned, and left — but there was nowhere an operator could see all of them at once. That is the surface a capability like this is supervised from, so it now exists: every live session, whose account, since when, the reason the operator gave, and one control to end any of them. Separately, every response the admin console produces carried no cache directive at all. That is signed-in operator data — customer rosters, order detail, the audit chain — and a shared proxy was left to make its own decision about storing it; all of it is now marked private and must-revalidate, and framed with a content length. **Added:** *A screen showing who is inside a customer's account right now* — `/admin/impersonation` lists every live support session across all operators: who is in, whose account, when it started, when it expires on its own, and the reason they gave for opening it. Each row carries a control to end that session, which also requires a reason. Ending one stops that operator on their very next request rather than whenever their cookie would have expired. Reading the screen needs no special permission — seeing that a session is running is oversight, and restricting oversight to the same role that can start sessions is how a capability stops being watched. Ending one takes the same owner-only authority as starting one. **Fixed:** *The release pipeline no longer reports a healthy publish as unfinished* — The publish step looked up the most recent run of the publish workflow rather than the run for the tag it had just pushed. GitHub takes a few seconds to register a run, so within that window it watched the PREVIOUS release's run — already finished, already green — and then reported that the registry did not have the new version yet. It now matches the run by tag, waits for it to appear, and polls the registry for a bounded interval before concluding anything, so a slow propagation is waited out rather than announced as a problem. · *A transient Cloudflare error no longer stops a deploy* — Applying database migrations occasionally fails with an authorization error that clears on its own moments later with the same credentials. It reads exactly like a broken token and is not one. The step now retries with backoff, and if it still fails the message says how to tell the two apart: a token that lists database write access was the transient case, while one narrowed to read-only genuinely needs re-authenticating. · *Five copies of the same control-byte scanner became one call* — Business hours, carrier rates, delivery estimates, shipping labels and shipping zones each carried a character-for-character copy of the same six-line loop. All five now call the framework's own predicate. The accepted and rejected sets are unchanged — verified codepoint by codepoint before the change, which is how the one difference that mattered was caught: the framework's default permits a tab, and these fields do not. · *Comments describing the outbound TLS posture match the code again* — Four comments still described payment-processor connections as pinning a hand-built handshake configuration. That was removed when the framework's own offer gained a classical option, at which point the pin had become the downgrade it was meant to avoid. The comments said the opposite of what the code did, in the place an operator reading about outbound TLS would look. **Security:** *Admin responses are no longer cacheable by a shared proxy* — Every JSON and HTML response from the admin console was served with no `Cache-Control` header whatsoever, which leaves an intermediary free to decide for itself whether to store it. The content is signed-in operator data: customer rosters, order detail, the audit chain, captured error text. All of it now carries `private, no-cache, must-revalidate` and a `Content-Length`, so a response is framed and an intermediary is told plainly not to keep it. The console keeps its existing `noindex`.
|
|
12
|
+
|
|
11
13
|
- v0.5.23 (2026-08-11) — **An operator can open the storefront as a customer to see what they are seeing — for an hour, with everything they do recorded, and with no way to take the account over.** Support work routinely needs the operator to see the store the way one specific customer sees it: to reproduce a cart total that looks wrong, a delivery estimate that will not appear, a discount that should apply and does not. Signing in as the customer is the obvious way to do that and the worst one — every action then reads as the customer's own, nothing tells the customer it happened, and a forgotten session stays open indefinitely. This is that capability built so none of those are true. An owner opens a customer's page, states a reason, and browses as them for an hour. The session shows as a banner on every page, ends the moment it is left, and appears on the customer's own account page with the reason the operator gave. The operator can act — fix the cart, correct an address, place the order — but everything that decides who owns the account is closed to them, so a support visit cannot become a takeover and cannot lock the customer out. **Added:** *View the storefront as a customer, from their page in the console* — A "View as customer" panel on each customer's admin page opens a support session. A reason is required, and it is not decoration: it is stored with the session and shown to the customer. Following the link lands on a confirmation naming the account and the stated reason before anything begins — so a link that is prefetched, previewed by a chat client, or fetched by a scanner cannot start a session on the operator's behalf, and the operator sees whose account they are entering before they are in it. The session lasts an hour. · *The customer is told, on their own account page* — An "Account access by our support team" section lists every session opened on the account: when, the reason the operator gave, and whether one is in progress right now. It is delivered this way rather than by email deliberately. This store keeps customer email as a hash, so there is no address to write to, and a notice that cannot be sent is not a notice — recording one as sent would put a line in an audit trail that never happened. Rendered on the account page it needs no address and cannot silently fail. A store that has wired its own plaintext-address store and a notification dispatcher can additionally pass the notifications handle to the primitive and get the out-of-band message as well. · *A banner on every page, and one control to leave* — While a session is live every storefront page carries a banner naming the customer and a button that ends it. It is filled in by the same small script that corrects the cart count, which is what lets it appear on cached pages too — those are served as one identical body to every visitor, so nothing about who is looking can be baked into them. Browsing the catalog during a support session therefore looks like a support session, not like an ordinary visit. **Security:** *A support session cannot become a takeover* — Everything that decides who owns the account is refused for the duration: enrolling or revoking a passkey, linking an identity provider, mailing a sign-in link, changing the email address, deleting the account, and exporting the whole record. The refusals are enforced at one point ahead of every route rather than inside each handler, so a route added under one of those paths later is closed by where it lives rather than by its author remembering. Each attempt is recorded against the session. · *Signing out ends the session, and does not sign the customer out* — The account page's own sign-out control revokes every session the customer has, on every device, and invalidates their pending sign-in links. An operator reaching for it means "get me out of here". Left alone it would have signed the customer out of their phone and laptop because someone helped them — the precise lock-out this feature promises an operator cannot cause. It ends the impersonation instead. · *Ending a session takes effect on the next request* — The session behind the cookie is re-read on every request made under it, so ending or revoking one stops the operator on their very next click rather than whenever the cookie would have expired. That check fails closed: where the equivalent gate for ordinary shoppers deliberately fails open so a database blip cannot sign everyone out, here a blip must not extend one person's authority over someone else's account. · *The handoff link is single-use* — The link is spent by being used. It remains in browser history and anywhere it was pasted, so a second holder must not be able to mint their own session from it — and cannot: the session's authority moves into a cookie bound to the browser that redeemed it. Two holders racing the same link resolve to exactly one, because the claim is a conditional write rather than a check followed by a write. · *Opening a session is its own permission* — `customers.impersonate` is separate from `customers.write`. A manager who can edit a customer record cannot become one; only the owner role holds it. A refusal names the missing authority in the audit record rather than reporting a generic write denial.
|
|
12
14
|
|
|
13
15
|
- v0.5.22 (2026-08-11) — **A postal address written in Arabic or Hebrew can be saved again, a pixel event whose provider replied with a control byte stops re-sending forever, and the vendored framework moves to 0.18.24.** The previous release began refusing Unicode bidirectional characters in stored text, to stop a value being made to display as something other than what it says. It refused too many. The bidirectional set contains two groups that do not carry the same risk: the overrides and isolates, which reverse the display order of a whole run and have no legitimate use in a shop's stored text, and the direction marks, which resolve the direction of neighbouring characters and are simply how a Latin house number inside an Arabic street name is written. Refusing the marks turned away ordinary right-to-left addresses, names and messages at the point of sale. Only the overrides and isolates are refused now, and the marks are accepted wherever prose is accepted. Separately, a marketing pixel whose provider replied with a control byte could never record that reply, so the event stayed queued and the same request was re-sent on every scheduler tick indefinitely. **Changed:** *Vendored framework refreshed to 0.18.24* — Advances the vendored framework from 0.18.21 to 0.18.24, and the store inherits the following without application changes. A certificate revocation response that declared one signature algorithm while carrying another was accepted: the field the verifier reads to choose its digest was never checked against the key it verified with. Nothing was forgeable through it — a response signed by any other key was still refused — but what a response claimed and what was actually checked could differ silently, and a mismatch is now refused. In Certificate Transparency, a consistency proof was rebuilt against the log's new tree but not against the tree the operator had pinned, except when the pinned size happened to be a power of two; a log that quietly rewrote history before the pinned point would still have passed at eleven of the sixteen smallest sizes. Both roots are now rebuilt and both are checked. A tree is also consistent with itself, which the walk previously refused at most sizes — an operator re-checking a log that had not grown was told it was inconsistent. The bundled certificate toolkit additionally clears every key-establishment secret and password copy it allocates once the derivation has consumed it. **Fixed:** *A right-to-left address, name or message is accepted again* — U+200E, U+200F and U+061C are direction marks. They carry no text of their own; they tell the renderer which way to run the characters beside them, which is what makes a Latin house number inside an Arabic street name, or a Latin brand inside a Hebrew sentence, display in the right order. A correctly written Saudi or Israeli address routinely contains one. The previous release folded them in with the bidirectional overrides and refused both, so those addresses were rejected at checkout — and the refusal said the value contained control bytes, sending the customer to look for something that was not there. Every field that takes prose accepts the marks again: shipping and billing addresses, recipient and company names, gift messages, review titles and bodies, customer notes, support tickets, knowledge-base articles. The overrides and isolates — U+202A to U+202E and U+2066 to U+2069, the half that reverses a run — stay refused, and a refusal now names the character class it actually found. · *A pixel event whose provider replied with a control byte no longer re-sends forever* — Recording a dispatch validates the provider's response body before it marks the event delivered. The previous release tightened that validation to refuse control characters, which the surrounding contract explicitly permits — a provider's reply is captured for an operator to read, and it is not the shop's to reject. The validation threw, the event was never marked delivered, and the scheduler re-sent the identical request on every tick from then on. The response body is screened only for invisible characters now, which is what it was ever meant to catch, so a reply the shop dislikes is stored rather than retried. · *A failed broadcast's reason is fully scrubbed before it reaches the ledger* — When a message in an email campaign fails, the mailer's error is folded onto one line and stored against that recipient. The expression doing the folding replaced only the first control character it found, so a multi-line error left the rest in the stored row. All of them are removed now. · *A category's hero image URL is held to the same rule as a blog post's* — Both are an operator-supplied image URL emitted into a single attribute, and a line break in one is never legitimate. The blog article's field refused them; the category navigation's did not. It does now. **Security:** *Refusing a bidirectional override still holds, and now says what it found* — The protection the previous release added is unchanged where it matters: a value carrying U+202A to U+202E or U+2066 to U+2069 is still refused everywhere it was, including a link URL inside a marketing email, where the plain-text part of the message prints that URL for the recipient to read. What changed is the blast radius. A URL, a slug or an identifier is machine-plain and now refuses the direction marks as well; prose does not, because prose in a right-to-left script needs them. The two substrates that parse search filters are held to the same set by a test that compares them across every character in the basic multilingual plane, so the edge and the container cannot come apart on it.
|
package/SECURITY.md
CHANGED
|
@@ -296,7 +296,12 @@ node -e "
|
|
|
296
296
|
closed. The handoff link is single-use and cannot be spent by a prefetch.
|
|
297
297
|
Every request made under the session lands in an append-only log, and the
|
|
298
298
|
customer sees each session — with the reason the operator gave — on their
|
|
299
|
-
own account page.
|
|
299
|
+
own account page. Operators get the other half of that view at
|
|
300
|
+
`/admin/impersonation`: every live session at once, with one control to end
|
|
301
|
+
any of them. Reading it needs no special permission, because restricting
|
|
302
|
+
oversight to the role that can start sessions is how a capability stops
|
|
303
|
+
being watched; ending one takes the same owner-only authority as starting
|
|
304
|
+
one, and takes effect on that operator's next request.
|
|
300
305
|
- **Text one person writes and another reads is screened for invisible
|
|
301
306
|
characters.** A Unicode bidirectional override reverses the display
|
|
302
307
|
order of everything after it while itself rendering as nothing, so
|
package/lib/admin.js
CHANGED
|
@@ -691,19 +691,22 @@ function _problem(res, status, code, detail) {
|
|
|
691
691
|
});
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
+
// Console HTML through the framework renderer — see `_json` for why. The
|
|
695
|
+
// `x-robots-tag` rides `opts.headers`, which merges OVER the renderer's
|
|
696
|
+
// defaults, so the admin console keeps its noindex while gaining the
|
|
697
|
+
// Cache-Control and Content-Length the hand-rolled version omitted.
|
|
694
698
|
function _sendHtml(res, status, html) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
700
|
-
if (res.end) res.end(html); else res.send(html);
|
|
699
|
+
b.render.htmlString(res, html, {
|
|
700
|
+
status: status,
|
|
701
|
+
headers: { "X-Robots-Tag": "noindex, nofollow" },
|
|
702
|
+
});
|
|
701
703
|
}
|
|
702
704
|
|
|
703
705
|
function _redirect(res, location) {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
706
|
+
// `b.render.redirect` additionally validates the 3xx range and refuses an
|
|
707
|
+
// empty location — a redirect to nowhere is a blank page an operator has to
|
|
708
|
+
// debug from the network tab.
|
|
709
|
+
b.render.redirect(res, location, { status: 303 });
|
|
707
710
|
}
|
|
708
711
|
|
|
709
712
|
// Single classifier for a thrown admin error → an operator-safe outcome,
|
|
@@ -983,7 +986,7 @@ function mount(router, deps) {
|
|
|
983
986
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
984
987
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
985
988
|
// notice when the salesReports primitive isn't wired.
|
|
986
|
-
var navAvailable = { analytics: !!deps.analytics, 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, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, 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, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, winback: !!winback, operators: !!operatorAccounts, inbox: !!operatorInbox };
|
|
989
|
+
var navAvailable = { analytics: !!deps.analytics, 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, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, 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, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, winback: !!winback, operators: !!operatorAccounts, inbox: !!operatorInbox, impersonation: !!customerImpersonation };
|
|
987
990
|
|
|
988
991
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
989
992
|
|
|
@@ -1117,11 +1120,18 @@ function mount(router, deps) {
|
|
|
1117
1120
|
};
|
|
1118
1121
|
}
|
|
1119
1122
|
|
|
1123
|
+
// Every admin JSON response goes through the framework renderer rather than
|
|
1124
|
+
// a hand-rolled stringify-and-end. Composing it here rather than at the 300+
|
|
1125
|
+
// call sites means the whole surface gains what the helper was missing:
|
|
1126
|
+
//
|
|
1127
|
+
// Cache-Control: private, no-cache, must-revalidate — this is signed-in
|
|
1128
|
+
// OPERATOR data (customer rosters, order detail, audit rows) and it was
|
|
1129
|
+
// being served with no cache directive at all, which leaves a shared
|
|
1130
|
+
// proxy free to make its own decision about storing it.
|
|
1131
|
+
// Content-Length — a framed response, so a client can tell a truncated
|
|
1132
|
+
// body from a complete one.
|
|
1120
1133
|
function _json(res, status, obj) {
|
|
1121
|
-
|
|
1122
|
-
if (res.setHeader) res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1123
|
-
var body = JSON.stringify(obj);
|
|
1124
|
-
if (res.end) res.end(body); else res.send(body);
|
|
1134
|
+
b.render.json(res, obj, { status: status });
|
|
1125
1135
|
}
|
|
1126
1136
|
|
|
1127
1137
|
// ---- products -------------------------------------------------------
|
|
@@ -1706,11 +1716,10 @@ function mount(router, deps) {
|
|
|
1706
1716
|
// ML-KEM hybrid group answers with a handshake_failure (TLS alert 40);
|
|
1707
1717
|
// that surfaces below as the same clean 502 `source-fetch-failed`
|
|
1708
1718
|
// (`e.message` carries the alert) the operator already retries by
|
|
1709
|
-
// re-submitting.
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
// host that can't meet the PQC list upload the file directly instead.
|
|
1719
|
+
// re-submitting. No caller-supplied TLS agent is pinned here, and none
|
|
1720
|
+
// is pinned on the processor dials either — every outbound dial in the
|
|
1721
|
+
// shop holds the framework's PQC-first default. Operators sourcing from
|
|
1722
|
+
// a host that can't meet the PQC list upload the file directly instead.
|
|
1714
1723
|
if (r2) {
|
|
1715
1724
|
// Fetch → store → attach, shared by the JSON upload route and the
|
|
1716
1725
|
// browser POST alias. Throws TypeError on bad input (mapped to 400);
|
|
@@ -3991,6 +4000,94 @@ function mount(router, deps) {
|
|
|
3991
4000
|
}));
|
|
3992
4001
|
}
|
|
3993
4002
|
|
|
4003
|
+
// ---- who is in someone's account right now --------------------------
|
|
4004
|
+
//
|
|
4005
|
+
// A capability that lets one person browse as another needs a place where
|
|
4006
|
+
// ALL of its live uses are visible at once. Per-customer history answers
|
|
4007
|
+
// "was my account opened"; this answers the operator-side question —
|
|
4008
|
+
// who is inside an account at this moment, whose, since when, and why —
|
|
4009
|
+
// and gives a supervisor one control to end any of them.
|
|
4010
|
+
//
|
|
4011
|
+
// Read gate: `R` admits any authenticated operator. Seeing that a session
|
|
4012
|
+
// is running is oversight, and restricting oversight to the role that can
|
|
4013
|
+
// start sessions is how a capability stops being watched. Ending one is a
|
|
4014
|
+
// `W` and takes the same owner-only permission as starting one.
|
|
4015
|
+
if (customerImpersonation) {
|
|
4016
|
+
router.get("/admin/impersonation", _pageOrApi(true,
|
|
4017
|
+
R(async function (req, res) {
|
|
4018
|
+
// A failed lookup is NOT an empty list. The HTML page can degrade to
|
|
4019
|
+
// a notice, but a machine reading this endpoint would take
|
|
4020
|
+
// `200 { rows: [] }` as "nobody is in anyone's account" at exactly
|
|
4021
|
+
// the moment oversight is unavailable — the one answer this surface
|
|
4022
|
+
// must never give wrongly. Say unavailable instead.
|
|
4023
|
+
var rows;
|
|
4024
|
+
try { rows = await customerImpersonation.currentlyImpersonating(); }
|
|
4025
|
+
catch (_e) {
|
|
4026
|
+
return _problem(res, 503, "sessions-unavailable",
|
|
4027
|
+
"The live session list could not be read. This is not a report that no sessions are active.");
|
|
4028
|
+
}
|
|
4029
|
+
_json(res, 200, { rows: rows });
|
|
4030
|
+
}),
|
|
4031
|
+
async function (req, res) {
|
|
4032
|
+
var rows = [];
|
|
4033
|
+
var notice = null;
|
|
4034
|
+
// Read-only oversight screen: a lookup failure degrades to an empty
|
|
4035
|
+
// table with a notice rather than a 500, so the page an operator
|
|
4036
|
+
// checks during an incident is never the page that is down.
|
|
4037
|
+
try { rows = await customerImpersonation.currentlyImpersonating(); }
|
|
4038
|
+
catch (_e) { rows = []; notice = "The session list is temporarily unavailable."; }
|
|
4039
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4040
|
+
_sendHtml(res, 200, renderAdminImpersonation({
|
|
4041
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
4042
|
+
rows: rows, notice: notice,
|
|
4043
|
+
ended: url && url.searchParams.get("ended") === "1",
|
|
4044
|
+
error: url && url.searchParams.get("imp_err"),
|
|
4045
|
+
}));
|
|
4046
|
+
},
|
|
4047
|
+
));
|
|
4048
|
+
|
|
4049
|
+
router.post("/admin/impersonation/:id/revoke", _pageOrApi(false,
|
|
4050
|
+
W("impersonation.revoke", async function (req, res) {
|
|
4051
|
+
var reason = (req.body && req.body.reason) || "";
|
|
4052
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
4053
|
+
return _problem(res, 400, "bad-request", "A reason is required to end another operator's session.");
|
|
4054
|
+
}
|
|
4055
|
+
var out;
|
|
4056
|
+
try {
|
|
4057
|
+
out = await customerImpersonation.revoke({
|
|
4058
|
+
impersonation_id: req.params.id, reason: reason.trim(),
|
|
4059
|
+
// WHO revoked it. Without this the audit row names the operator
|
|
4060
|
+
// being removed as the one who removed themselves.
|
|
4061
|
+
revoked_by: req.operatorActor && req.operatorActor.operator_id,
|
|
4062
|
+
});
|
|
4063
|
+
} catch (e) {
|
|
4064
|
+
if (e && e.code === "IMPERSONATION_NOT_FOUND") return _problem(res, 404, "not-found");
|
|
4065
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4066
|
+
throw e;
|
|
4067
|
+
}
|
|
4068
|
+
_json(res, 200, out);
|
|
4069
|
+
return { id: req.params.id };
|
|
4070
|
+
}),
|
|
4071
|
+
async function (req, res) {
|
|
4072
|
+
var reason = (req.body && req.body.reason) || "";
|
|
4073
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
4074
|
+
return _redirect(res, "/admin/impersonation?imp_err=" +
|
|
4075
|
+
encodeURIComponent("Give a reason before ending someone else's session."));
|
|
4076
|
+
}
|
|
4077
|
+
try {
|
|
4078
|
+
await customerImpersonation.revoke({
|
|
4079
|
+
impersonation_id: req.params.id, reason: reason.trim(),
|
|
4080
|
+
revoked_by: req.operatorActor && req.operatorActor.operator_id,
|
|
4081
|
+
});
|
|
4082
|
+
} catch (e) {
|
|
4083
|
+
var n = _safeNotice(e, "impersonation.revoke");
|
|
4084
|
+
return _redirect(res, "/admin/impersonation?imp_err=" +
|
|
4085
|
+
encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
|
|
4086
|
+
}
|
|
4087
|
+
return _redirect(res, "/admin/impersonation?ended=1");
|
|
4088
|
+
}));
|
|
4089
|
+
}
|
|
4090
|
+
|
|
3994
4091
|
// ---- customer notes (add) ------------------------------------------
|
|
3995
4092
|
// Scoped to the :id customer — the note attaches to the path customer.
|
|
3996
4093
|
// Composes customerNotes.addNote (the body is required + length-capped by
|
|
@@ -15107,6 +15204,9 @@ var ADMIN_NAV_ITEMS = [
|
|
|
15107
15204
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
15108
15205
|
{ key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
|
|
15109
15206
|
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
15207
|
+
// Sits beside Audit rather than under Customers on purpose: it is an
|
|
15208
|
+
// oversight surface, not a customer-management one.
|
|
15209
|
+
{ key: "impersonation", href: "/admin/impersonation", label: "Customer sessions", requires: "impersonation" },
|
|
15110
15210
|
{ key: "errors", href: "/admin/errors", label: "Errors", requires: "errorLog" },
|
|
15111
15211
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
15112
15212
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
@@ -15757,6 +15857,62 @@ var AUDIT_OUTCOME_FILTERS = ["success", "failure", "denied"];
|
|
|
15757
15857
|
// class is un-lintable (no detector can tell a render-concat apart from a
|
|
15758
15858
|
// string-build), so the XSS-payload regression test is the guarantee — see
|
|
15759
15859
|
// test/layer-2-integration/admin-audit-console.test.js.
|
|
15860
|
+
// Who is inside a customer's account right now.
|
|
15861
|
+
//
|
|
15862
|
+
// The per-customer history answers "was my account opened". This is the other
|
|
15863
|
+
// half, and it is the one a supervisor needs: every live session at once, with
|
|
15864
|
+
// one control to end any of them. A capability that lets one person browse as
|
|
15865
|
+
// another is only as accountable as the place you can watch it from.
|
|
15866
|
+
function renderAdminImpersonation(opts) {
|
|
15867
|
+
opts = opts || {};
|
|
15868
|
+
var rows = opts.rows || [];
|
|
15869
|
+
var notice = opts.notice
|
|
15870
|
+
? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
15871
|
+
if (opts.error) {
|
|
15872
|
+
notice += "<div class=\"banner banner--warn\">" + _htmlEscape(String(opts.error)) + "</div>";
|
|
15873
|
+
}
|
|
15874
|
+
if (opts.ended) {
|
|
15875
|
+
notice += "<div class=\"banner\">Session ended. The operator is refused on their next request.</div>";
|
|
15876
|
+
}
|
|
15877
|
+
|
|
15878
|
+
var body = notice +
|
|
15879
|
+
"<h2 class=\"subhead\">Active customer sessions</h2>" +
|
|
15880
|
+
"<p class=\"meta\">Operators currently viewing the storefront as a customer. A session " +
|
|
15881
|
+
"ends on its own after an hour, when the operator leaves it, or when you end it here — " +
|
|
15882
|
+
"in every case the operator is refused on their next request rather than at cookie " +
|
|
15883
|
+
"expiry. Each customer also sees their own visits on their account page.</p>";
|
|
15884
|
+
|
|
15885
|
+
if (!rows.length) {
|
|
15886
|
+
body += "<p class=\"empty\">Nobody is viewing a customer account right now.</p>";
|
|
15887
|
+
return _renderAdminShell(opts.shop_name, "Customer sessions", body, "impersonation", opts.nav_available);
|
|
15888
|
+
}
|
|
15889
|
+
|
|
15890
|
+
var trs = rows.map(function (r) {
|
|
15891
|
+
var enc = encodeURIComponent(String(r.id));
|
|
15892
|
+
return "<tr>" +
|
|
15893
|
+
"<td><code>" + _htmlEscape(String(r.operator_id || "—")) + "</code></td>" +
|
|
15894
|
+
"<td><a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(String(r.customer_id))) +
|
|
15895
|
+
"\"><code>" + _htmlEscape(String(r.customer_id)) + "</code></a></td>" +
|
|
15896
|
+
"<td>" + _htmlEscape(_fmtTs(r.started_at)) + "</td>" +
|
|
15897
|
+
"<td>" + _htmlEscape(_fmtTs(r.expires_at)) + "</td>" +
|
|
15898
|
+
"<td>" + _htmlEscape(String(r.reason || "—")) + "</td>" +
|
|
15899
|
+
"<td><form method=\"post\" action=\"/admin/impersonation/" + _htmlEscape(enc) + "/revoke\">" +
|
|
15900
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"200\" placeholder=\"Why end it?\">" +
|
|
15901
|
+
"<button class=\"btn btn--danger\" type=\"submit\">End session</button>" +
|
|
15902
|
+
"</form></td>" +
|
|
15903
|
+
"</tr>";
|
|
15904
|
+
}).join("");
|
|
15905
|
+
|
|
15906
|
+
body +=
|
|
15907
|
+
"<table class=\"table\"><thead><tr>" +
|
|
15908
|
+
"<th scope=\"col\">Operator</th><th scope=\"col\">Customer</th>" +
|
|
15909
|
+
"<th scope=\"col\">Started</th><th scope=\"col\">Expires</th>" +
|
|
15910
|
+
"<th scope=\"col\">Stated reason</th><th scope=\"col\">End</th>" +
|
|
15911
|
+
"</tr></thead><tbody>" + trs + "</tbody></table>";
|
|
15912
|
+
|
|
15913
|
+
return _renderAdminShell(opts.shop_name, "Customer sessions", body, "impersonation", opts.nav_available);
|
|
15914
|
+
}
|
|
15915
|
+
|
|
15760
15916
|
function renderAdminAudit(opts) {
|
|
15761
15917
|
opts = opts || {};
|
|
15762
15918
|
var rows = opts.rows || [];
|
|
@@ -24365,6 +24521,7 @@ module.exports = {
|
|
|
24365
24521
|
renderAdminLoyaltyReward: renderAdminLoyaltyReward,
|
|
24366
24522
|
renderAdminConfirm: renderAdminConfirm,
|
|
24367
24523
|
renderAdminAudit: renderAdminAudit,
|
|
24524
|
+
renderAdminImpersonation: renderAdminImpersonation,
|
|
24368
24525
|
renderAdminPickupLocations: renderAdminPickupLocations,
|
|
24369
24526
|
renderAdminPickups: renderAdminPickups,
|
|
24370
24527
|
renderAdminGiftWraps: renderAdminGiftWraps,
|
package/lib/asset-manifest.json
CHANGED
package/lib/business-hours.js
CHANGED
|
@@ -89,12 +89,11 @@ var SEARCH_DAYS = 366 * 2; // worst-case "find next open" walk — 2 calen
|
|
|
89
89
|
|
|
90
90
|
// ---- validators ---------------------------------------------------------
|
|
91
91
|
|
|
92
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
93
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
94
|
+
// these are single-line operator fields where a tab has no business.
|
|
92
95
|
function _hasControlByte(s) {
|
|
93
|
-
|
|
94
|
-
var cc = s.charCodeAt(i);
|
|
95
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
96
|
-
}
|
|
97
|
-
return false;
|
|
96
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
function _slug(s) {
|
package/lib/carrier-rates.js
CHANGED
|
@@ -77,12 +77,11 @@ var b = require("./vendor/blamejs");
|
|
|
77
77
|
|
|
78
78
|
// ---- validators ---------------------------------------------------------
|
|
79
79
|
|
|
80
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
81
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
82
|
+
// these are single-line operator fields where a tab has no business.
|
|
80
83
|
function _hasControlByte(s) {
|
|
81
|
-
|
|
82
|
-
var cc = s.charCodeAt(i);
|
|
83
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
84
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
function _slug(s) {
|
|
@@ -635,11 +635,22 @@ function create(opts) {
|
|
|
635
635
|
// The operator dashboard's "who's currently impersonating whom"
|
|
636
636
|
// surface. Returns every `active` row across all operators,
|
|
637
637
|
// newest-first.
|
|
638
|
-
|
|
638
|
+
// Filters on the CLOCK as well as the status column.
|
|
639
|
+
//
|
|
640
|
+
// Authority expires by timestamp — `verifyImpersonationToken` and the
|
|
641
|
+
// per-request liveness read both refuse an elapsed row immediately — while
|
|
642
|
+
// the `status` column only catches up when `cleanupExpired` next runs. A
|
|
643
|
+
// query on status alone therefore reports sessions as live in the window
|
|
644
|
+
// between those two, and longer if the sweep is delayed or was never
|
|
645
|
+
// scheduled. On an oversight screen that is the wrong direction to be
|
|
646
|
+
// wrong in: it shows a supervisor people inside accounts who are not,
|
|
647
|
+
// which is the fastest way to teach them the screen is noise.
|
|
648
|
+
currentlyImpersonating: async function (listOpts) {
|
|
649
|
+
var now = (listOpts && listOpts.now != null) ? listOpts.now : _now();
|
|
639
650
|
var r = await query(
|
|
640
|
-
"SELECT * FROM impersonations WHERE status = 'active' " +
|
|
651
|
+
"SELECT * FROM impersonations WHERE status = 'active' AND expires_at > ?1 " +
|
|
641
652
|
"ORDER BY started_at DESC, id DESC",
|
|
642
|
-
[],
|
|
653
|
+
[now],
|
|
643
654
|
);
|
|
644
655
|
return r.rows.map(_projectRow);
|
|
645
656
|
},
|
|
@@ -807,6 +818,12 @@ function create(opts) {
|
|
|
807
818
|
}
|
|
808
819
|
var id = _uuid(input.impersonation_id, "impersonation_id");
|
|
809
820
|
var reason = _requiredString(input.reason, "reason", MAX_END_REASON_LEN);
|
|
821
|
+
// Optional so an existing caller keeps working; when present it is the
|
|
822
|
+
// operator performing the revocation, and it is what the audit row
|
|
823
|
+
// records as the actor.
|
|
824
|
+
var revokedBy = input.revoked_by == null
|
|
825
|
+
? null
|
|
826
|
+
: _operatorId(input.revoked_by, "revoked_by");
|
|
810
827
|
var existing = await _getRow(id);
|
|
811
828
|
if (!existing) {
|
|
812
829
|
var miss = new Error("customer-impersonation.revoke: impersonation not found");
|
|
@@ -825,12 +842,25 @@ function create(opts) {
|
|
|
825
842
|
);
|
|
826
843
|
var revoked = Number(r.rowCount || 0) > 0;
|
|
827
844
|
if (revoked) {
|
|
845
|
+
// The actor is whoever DID the revoking, which is usually not the
|
|
846
|
+
// operator being removed. Attributing it to `existing.operator_id`
|
|
847
|
+
// wrote an audit row saying they ended their own session — on the one
|
|
848
|
+
// feature whose entire justification is an accurate record of who did
|
|
849
|
+
// what to whose account, that is a falsified line. `revoked_by` falls
|
|
850
|
+
// back to the session's own operator so a self-revoke still reads
|
|
851
|
+
// correctly and an older caller keeps working.
|
|
828
852
|
await _audit(
|
|
829
853
|
"revoke",
|
|
830
|
-
existing.operator_id,
|
|
854
|
+
revokedBy || existing.operator_id,
|
|
831
855
|
id,
|
|
832
856
|
{ status: "active" },
|
|
833
|
-
{
|
|
857
|
+
{
|
|
858
|
+
status: "revoked", ended_at: now, end_reason: reason,
|
|
859
|
+
revoked_by: revokedBy || existing.operator_id,
|
|
860
|
+
// Named explicitly so an auditor reading the row can tell a
|
|
861
|
+
// supervisor's intervention from an operator closing their own.
|
|
862
|
+
self_revoked: !revokedBy || revokedBy === existing.operator_id,
|
|
863
|
+
},
|
|
834
864
|
);
|
|
835
865
|
}
|
|
836
866
|
return { revoked: revoked };
|
package/lib/delivery-estimate.js
CHANGED
|
@@ -132,12 +132,11 @@ var DAY_MS = b.constants.TIME.days(1);
|
|
|
132
132
|
|
|
133
133
|
// ---- validators ---------------------------------------------------------
|
|
134
134
|
|
|
135
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
136
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
137
|
+
// these are single-line operator fields where a tab has no business.
|
|
135
138
|
function _hasControlByte(s) {
|
|
136
|
-
|
|
137
|
-
var cc = s.charCodeAt(i);
|
|
138
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
139
|
-
}
|
|
140
|
-
return false;
|
|
139
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
141
140
|
}
|
|
142
141
|
|
|
143
142
|
function _zone(s, label) {
|
package/lib/payment.js
CHANGED
|
@@ -176,9 +176,11 @@ function _formEncode(obj, prefix) {
|
|
|
176
176
|
//
|
|
177
177
|
// b.httpClient already routes every dial through b.ssrfGuard (private /
|
|
178
178
|
// loopback / link-local / reserved / cloud-metadata IP classes refused) and
|
|
179
|
-
// pins the TCP connect to the guard's resolved IP set
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// pins the TCP connect to the guard's resolved IP set, so DNS rebinding can't
|
|
180
|
+
// flip the answer between check and connect. (These dials no longer supply a
|
|
181
|
+
// caller-built TLS agent at all — see the note at the head of this file — so
|
|
182
|
+
// the pinning applies to the framework's own client.) The remaining gap is a
|
|
183
|
+
// HOST allowlist: nothing today
|
|
182
184
|
// stops an `opts.apiBase` pointed at an unexpected public host (config
|
|
183
185
|
// injection, or a future code path that derives the base from request data)
|
|
184
186
|
// from reaching a non-PSP upstream. We pin `allowedHosts` to the configured
|
package/lib/shipping-labels.js
CHANGED
|
@@ -136,12 +136,11 @@ function _currency(c) {
|
|
|
136
136
|
|
|
137
137
|
// C0 + DEL refusal — same pattern orderTracking uses, kept as a
|
|
138
138
|
// charCodeAt walk so the `no-control-regex` ESLint rule stays clean.
|
|
139
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
140
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
141
|
+
// these are single-line operator fields where a tab has no business.
|
|
139
142
|
function _hasControlByte(s) {
|
|
140
|
-
|
|
141
|
-
var cc = s.charCodeAt(i);
|
|
142
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
143
|
-
}
|
|
144
|
-
return false;
|
|
143
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
function _shortText(s, label, max) {
|
package/lib/shipping-zones.js
CHANGED
|
@@ -93,12 +93,11 @@ var b = require("./vendor/blamejs");
|
|
|
93
93
|
|
|
94
94
|
// ---- validators ---------------------------------------------------------
|
|
95
95
|
|
|
96
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
97
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
98
|
+
// these are single-line operator fields where a tab has no business.
|
|
96
99
|
function _hasControlByte(s) {
|
|
97
|
-
|
|
98
|
-
var cc = s.charCodeAt(i);
|
|
99
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
100
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
function _slug(s) {
|
package/lib/storefront.js
CHANGED
|
@@ -12107,6 +12107,20 @@ function mount(router, deps) {
|
|
|
12107
12107
|
_hasCollections = !!deps.collections;
|
|
12108
12108
|
_hasCategoryNav = !!deps.categoryNavigation;
|
|
12109
12109
|
|
|
12110
|
+
// Deliberately NOT routed through `b.render.htmlString`, unlike the admin
|
|
12111
|
+
// console's equivalent helper.
|
|
12112
|
+
//
|
|
12113
|
+
// The renderer's default is `Cache-Control: private, no-cache,
|
|
12114
|
+
// must-revalidate`, which is right for the admin console — every response
|
|
12115
|
+
// there is signed-in operator data. This helper is not the same shape: it
|
|
12116
|
+
// serves anonymous, edge-cacheable pages (category, collection, help
|
|
12117
|
+
// article, opening hours) from the SAME function as the account pages.
|
|
12118
|
+
// Stamping `private` on all of them would tell the CDN not to cache exactly
|
|
12119
|
+
// the pages the edge exists to serve, trading a real performance loss for no
|
|
12120
|
+
// security gain on a page a logged-out visitor can already fetch.
|
|
12121
|
+
//
|
|
12122
|
+
// The per-route cache directives that do exist are set by the routes that
|
|
12123
|
+
// own them, which is where the public/private decision actually lives.
|
|
12110
12124
|
function _send(res, status, html) {
|
|
12111
12125
|
res.status(status);
|
|
12112
12126
|
res.setHeader && res.setHeader("content-type", "text/html; charset=utf-8");
|
package/lib/webhooks.js
CHANGED
|
@@ -46,9 +46,11 @@
|
|
|
46
46
|
* backoff retry schedule, and — if the endpoint never negotiates a
|
|
47
47
|
* supported group — lands in `webhook_dlq` after the fifth attempt.
|
|
48
48
|
* This is a deliberate posture choice, NOT a silent classical
|
|
49
|
-
* downgrade: the
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* downgrade: every outbound dial in the shop — this one and the payment
|
|
50
|
+
* processors alike — now runs on the framework's PQC-first default. The
|
|
51
|
+
* processor adapters used to pin a hand-built agent for their two fixed
|
|
52
|
+
* endpoints; that was removed once the framework's offer ended with a
|
|
53
|
+
* classical option, because by then the pin was the downgrade.
|
|
52
54
|
* An operator whose receiver can't meet the PQC list terminates TLS
|
|
53
55
|
* for it on a fronting proxy (Cloudflare, a load balancer) that does,
|
|
54
56
|
* or upgrades the receiver's TLS stack.
|
package/package.json
CHANGED