@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 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
- res.status(status);
696
- if (res.setHeader) {
697
- res.setHeader("content-type", "text/html; charset=utf-8");
698
- res.setHeader("x-robots-tag", "noindex, nofollow");
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
- res.status(303);
705
- if (res.setHeader) res.setHeader("location", location);
706
- if (res.end) res.end(); else res.send("");
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
- res.status(status);
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. We do NOT pin a classical-downgrade agent here the
1710
- // way the PSP adapters do for their two fixed endpoints an arbitrary
1711
- // operator-supplied URL is not a fixed processor edge, so holding the
1712
- // PQC default is the deliberate posture. Operators sourcing from a
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,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.23",
2
+ "version": "0.5.24",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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) {
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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
- currentlyImpersonating: async function () {
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
- { status: "revoked", ended_at: now, end_reason: reason },
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 };
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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 even on the PSP path's
180
- // caller-supplied TLS agent, so DNS rebinding can't flip the answer between
181
- // check and connect. The remaining gap is a HOST allowlist: nothing today
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
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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) {
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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 PSP adapters (lib/payment.js) pin a downgraded agent
50
- * for their two FIXED endpoints, but a webhook receiver URL is
51
- * operator-arbitrary, so the framework's PQC-first default is held.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.23",
3
+ "version": "0.5.24",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {