@blamejs/blamejs-shop 0.4.24 → 0.4.25

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.4.x
10
10
 
11
+ - v0.4.25 (2026-06-06) — **The admin role gate fails closed, payment-processor calls are host-pinned, the public catalog API stops leaking unpublished products, and privacy requests show their statutory deadline.** A security-hardening release. The staff role matrix now denies an unmapped admin action by default (owner-only) instead of leaving it manager-reachable; outbound Stripe and PayPal calls are pinned to the configured processor host with their idempotency keys validated before they leave; the public catalog API no longer honors a caller-supplied status filter, so draft and archived products can't be enumerated; the privacy-request console surfaces each request's statutory response deadline; and the Apple Pay well-known bot-guard exemption is tightened to an exact-path match. No migrations. **Changed:** *Privacy-request console shows the statutory response deadline* — Each export and erasure request now displays its statutory response deadline — one month under GDPR, 45 days under CCPA, 15 days under LGPD — derived from the request's recorded jurisdiction and timestamp, and flags an open request whose deadline has passed. Requests under no statutory regime show no deadline rather than an invented one. **Security:** *Admin role gate fails closed on unmapped actions* — The operator role matrix is now a declarative registry with role inheritance (owner ⊃ manager ⊃ viewer) validated at boot. A mutating admin action that isn't explicitly mapped to a permission now requires the owner role, instead of falling through to a manager-grantable default — so a newly added admin route can't be reached by a manager or viewer by accident. Existing mapped routes keep their grants. · *Public catalog API no longer leaks unpublished products* — GET /api/catalog/products accepted a caller-supplied ?status= filter and passed it straight through, so an unauthenticated request could list draft or archived products by asking for them. The endpoint now always lists only published products; operators browse other statuses through the authenticated admin catalog screens. · *Payment-processor calls are host-pinned with validated idempotency keys* — Every outbound Stripe and PayPal call is now pinned to the configured processor host (defense-in-depth over the existing private-address and metadata-endpoint blocking), and a malformed or non-HTTPS processor base URL fails at the first call rather than dialing in the clear. The idempotency key each call carries is validated before it leaves, refusing path-traversal, slash, and control-character shapes. · *Apple Pay well-known bot-guard exemption is exact-match* — The bot-guard skip for the Apple Pay domain-association path was matched as a prefix, which would also have exempted any sibling path beneath it. It is now an exact-path match, so only the single static association file skips the guard.
12
+
11
13
  - v0.4.24 (2026-06-06) — **Order timelines, in-console new-order alerts, partial refunds, a customer idea board, storefront sidebar widgets, and Stripe wallet checkout — alongside refund-accounting, inventory-race, and access-control hardening.** This release adds operator and storefront surfaces that compose primitives already in the tree, and closes a set of value-conservation, concurrency, and access-control gaps in the same areas. Operators get a single order-story timeline, an in-console inbox that flags new paid orders with an unread badge, a browser partial-refund control, and curated storefront sidebar widgets. Shoppers get a receipt download on their orders, a suggestion board, and Stripe wallet buttons (Apple Pay, Google Pay, Link) at checkout. The fixes make partial refunds return gift-card and loyalty value in exact proportion, make inventory and quote state transitions race-proof under concurrency, gate the privacy export/erasure routes behind the operator role matrix, and harden the audit log, gift-card ledger, session revocation, and magic-link throttling. Upgrade applies six new D1 migrations and adds one optional environment variable for Apple Pay. **Added:** *Order timeline on the admin order screen* — /admin/orders/:id now shows one chronological feed of the whole order story — state changes, payments and refunds, shipments and carrier tracking events, customer-service notes, returns, and shipping labels — instead of separate panels. Each source is optional; an unwired source simply drops out of the feed. Wire orderTimeline into admin.mount to enable it. · *New-order inbox with an unread badge* — When an order is paid, an entry lands in a new in-console inbox at /admin/inbox and the admin navigation shows an unread count, so a sale is visible on the next page load without polling. Mark a message read once actioned, or archive it. The inbox is addressed to a role broadcast, so a single shared-credential console still sees the badge. Wire operatorInbox into admin.mount to enable it. · *Partial refunds from the browser* — The order screen gains a Refunds panel: enter an amount and the framework charges it back through the payment provider, validates it server-side, and caps it at the order's remaining balance — an over-refund is refused before any money moves. The provider call is keyed so a double-click cannot refund twice. A refund that clears the balance moves the order to refunded; a smaller refund leaves it on its current track and records the slice. · *Customer receipt download* — A customer can download a receipt for their own order from the order page. The document streams in chunks rather than buffering, and the route is gated to the order's owner (and guest-order token holders, matching the existing order-page guard). · *Customer suggestion board* — Shoppers can submit and vote on product and feature ideas at /suggestions (linked from the footer). Operators triage from a new admin console: respond, move ideas along a status flow, merge duplicates, flag spam, or archive. Submissions are rate-limited and bot-guarded, the submitter's email is hashed before storage, and all free text is escaped on both the public board and the admin screen. · *Operator-curated storefront sidebar widgets* — Operators can place an ordered set of sidebar content blocks (newsletter signup, trust badges, social proof, size chart, countdown, featured collection, and more) per page across product, cart, search, and collection pages. The content is operator-authored and global, so it renders identically from the edge cache and the container. Manage widgets and per-page placement from the new Sidebar widgets admin screen. · *Stripe wallet checkout (Apple Pay, Google Pay, Link)* — The pay page now offers the Stripe Express Checkout Element, surfacing the wallet buttons a shopper's browser supports against the existing PaymentIntent. It degrades to the card form when no wallet is available or when payments are unconfigured. Apple Pay additionally needs the domain-association file served (see Migration) and each web domain registered with Stripe. · *Email-change guidance on the profile screen* — The profile screen now explains that a sign-in email cannot be changed in place: addresses are stored as a hash by design, so changing one means re-registering. The copy states what is and is not possible and why, rather than leaving the field unexplained. **Fixed:** *Partial refunds return gift-card and loyalty value in proportion* — A partial refund previously re-minted the entire gift-card spend and clawed back 100% of earned loyalty points the moment the order reached the refunded state, and restored nothing on a smaller slice — so a buyer could keep goods plus the full re-minted gift-card balance. Refunds now re-mint gift-card spend, claw earned loyalty, and restore redeemed loyalty points in exact proportion to the amount refunded, and a partial-then-final refund sequence converges on exactly the amount returned. · *Redeemed loyalty points are restored on refund* — Loyalty points spent as a checkout tender were never returned when the order was refunded, even though gift-card spend was — an inconsistency that cost the customer. Redeemed points are now restored on refund, in proportion to the refunded amount. · *Referral rewards reverse on refund or cancel* — A referral's qualifying first order being refunded or cancelled now rolls back the both-rewarded completion and decrements the referrer's leaderboard count, closing a buy-then-refund reward-farming path. The reversal is claimed once so a re-delivered refund webhook cannot double-reverse. · *Inventory and quote state transitions are race-proof* — Stock-transfer reconcile, inventory receive and reverse, write-off reversal, allocation commit, cycle-count finalize, and quote accept and convert all read state and then mutated it without an atomic guard, so two concurrent calls could both proceed — double-crediting a destination shelf, double-restocking, or minting two orders from one quote. Each transition now claims its state atomically and proceeds only if it won the claim. The stock-transfer and inventory-receipt lifecycles are additionally validated against a declared state machine. **Security:** *Privacy export and erasure now enforce the operator role matrix* — The data-subject export (full customer PII) and erasure routes did not pass through the role check every other mutating admin route uses, so a read-only viewer could export a customer's data and run an irreversible erasure. Both are now gated by the role matrix, and the mutating admin routes were swept to confirm each maps to a required permission. · *Operator audit log no longer self-reports tampering under concurrency* — Two operator actions recorded at the same instant could each read the same chain head and append rows sharing one previous-hash, forking the tamper-evident chain so verification reported a break where no tampering occurred. Audit appends are now serialized per chain, so the hash chain stays linear under concurrent writes. · *Gift-card ledger is now tamper-evident* — The gift-card balance ledger carried no cryptographic linkage, so a direct database edit that inflated a balance or dropped a debit passed balance reads undetected. Each ledger row now carries a per-card hash chain (the same construction the operator audit log uses), and a verification pass surfaces any insert, rewrite, reorder, or deletion. · *Erasure, passkey-revoke, and sign-out terminate live sessions* — The sealed sign-in cookie is self-validating for up to 14 days, so an erased or anonymized account stayed usable until the cookie expired. A per-customer session boundary now lets erasure, passkey revocation, and explicit sign-out invalidate every cookie minted before the boundary. A customer with no boundary is unaffected, so upgrading does not sign anyone out. · *Marketing email honors the suppression list everywhere* — Wishlist price-drop and back-in-stock alerts did not consult the marketing suppression list, and a newsletter one-click unsubscribe recorded the opt-out without adding the address to suppressions — so a hard-bounced, complained, or unsubscribed address could still be mailed by another flow. Both paths now consult and feed the suppression list, matching the transactional and campaign paths. · *Per-recipient throttle on magic-link sign-in* — Magic-link minting was throttled per source address only, so a distributed source could email-bomb one victim's inbox. A per-recipient limit now caps how often a link can be sent to a single address, alongside the existing per-source limit. · *Strict validation of inbound webhook idempotency keys* — The inbound webhook idempotency key was validated against a broad printable-ASCII pattern that accepted path-traversal shapes. It now uses the framework's strict idempotency-key guard, which refuses slash, backslash, and dot-dot sequences. The strict profile permits a space (harmless in the parameterized lookup) while refusing the traversal shapes. **Migration:** *Six new D1 migrations* — 0220 adds the gift-card ledger hash-chain columns; 0221, 0223, 0224, and 0225 add proportional-reversal tracking to gift-card redemptions, loyalty transactions, the loyalty earn log, and referral invitations; 0222 adds the per-customer session-revocation boundary table. All apply cleanly over existing rows (new columns default to nothing-reversed-yet; the revocation table is empty, which is the default-allow state). Run pending migrations and sync theme assets as usual. · *Optional APPLE_PAY_DOMAIN_ASSOCIATION environment variable* — To show the Apple Pay wallet button, set APPLE_PAY_DOMAIN_ASSOCIATION to the file contents Stripe provides (Stripe owns Apple merchant validation, so no Apple Developer account is needed) and register each web domain with Stripe. The shop serves the value verbatim at /.well-known/apple-developer-merchantid-domain-association on both the edge and container. Unset, the path returns 404 and the Apple Pay button stays hidden; card, Google Pay, Link, and PayPal are unaffected.
12
14
 
13
15
  - v0.4.23 (2026-06-06) — **A stolen sign-in cookie stops working on another device, payment-provider outages fail fast and recover, replayed payment webhooks are refused, and the staff audit log is rewrite-evident.** A security-hardening release. The signed-in session cookie now carries a device fingerprint — a cookie lifted from one browser softly signs out instead of working anywhere for two weeks. Payment-provider calls run behind a circuit breaker, so a provider brown-out fails fast into the existing payment-unavailable page and recovers automatically instead of hanging every checkout. A captured payment-webhook payload can no longer be replayed inside the signature tolerance window. And the staff-activity audit log is anchored with post-quantum checkpoint signatures, with a verification endpoint that detects any rewrite of its history. **Security:** *The sign-in cookie is pinned to the device that signed in* — The signed-in session cookie was tamper-proof but portable — exfiltrated once, it worked from any device until expiry. Signing in now embeds a fingerprint of the signing-in browser inside the sealed cookie, and every authenticated request re-checks it in constant time. A mismatch signs the visitor out gently to the sign-in page rather than failing the request mid-page; network changes don't trip it (the fingerprint deliberately excludes the IP address), and sessions issued before this release continue to work until they expire naturally. · *Payment-provider calls are circuit-broken and retried where safe* — A payment-provider brown-out previously failed every checkout serially, each waiting out the full network timeout. Provider calls now run behind a circuit breaker: repeated failures trip it, subsequent checkouts fail fast into the existing payment-unavailable page, and the circuit closes again when the provider recovers. Idempotent calls — reads and writes carrying an idempotency key — retry through brief blips; non-idempotent calls never retry. The TLS configuration for provider connections is unchanged. · *Replayed payment webhooks are refused* — A captured, validly-signed payment-webhook payload could be replayed within the signature timestamp tolerance and re-drive order transitions. Each event is now recorded on first receipt and a replay of the same event answers as an already-processed no-op — enforced with a single atomic write, so two concurrent deliveries of the same event also collapse to one. Replay records expire with the signature tolerance window. · *The staff audit log detects history rewrites* — Staff-activity audit rows were hash-linked, which catches tampering with a single row but not a consistent rewrite of the whole chain. The chain is now anchored with periodic checkpoint signatures (ML-DSA, the same signing the framework audit chain uses), and `/admin/operators/audit/verify` reports both link integrity and checkpoint verification — a rewritten history fails the check. Stores that disable destructive operations behind a second approver were also evaluated: with one active operator the gate would deadlock the store, so gift-card voids, refunds, and operator disables remain single-approver — role-gated, bounded, reversible, and audited — with the stance and its revisit condition documented in the module.
package/lib/admin.js CHANGED
@@ -98,30 +98,52 @@ var _errorLogSink = null;
98
98
  // the verb itself, not merely hidden in the nav. Read routes (`R`, no
99
99
  // audit action) demand no permission beyond a valid credential.
100
100
 
101
- // The three built-in roles and their permission grants. `owner` holds the
102
- // full set including operator management; `manager` covers catalog /
103
- // orders / customers / marketing writes; `viewer` is read-only it holds
104
- // NO write permission, so every `W`-wrapped route refuses it. The role set
105
- // is the v1-defensible surface; operators wanting finer-grained custom
106
- // roles compose lib/operator-roles.js on top.
107
- var OPERATOR_PERMISSIONS = Object.freeze([
108
- "catalog.write", // products, variants, prices, media, inventory, collections, merchandising, marketing content
109
- "orders.write", // orders, returns, exchanges, refunds, fulfilment, exports, quotes, gift cards
110
- "customers.write", // customer records, segments, notes, store credit
111
- "settings.write", // shop configuration
112
- "operators.manage", // create / disable / re-role other operators
113
- ]);
114
-
115
- var ROLE_GRANTS = Object.freeze({
116
- owner: Object.freeze(OPERATOR_PERMISSIONS.slice()),
117
- manager: Object.freeze(["catalog.write", "orders.write", "customers.write"]),
118
- viewer: Object.freeze([]),
101
+ // The three built-in roles, declared as a `b.permissions` registry. The
102
+ // registry gives role inheritance (owner manager viewer), wildcard
103
+ // scope coverage, and boot-time validation a typo in a scope string, an
104
+ // unknown `extends` target, or a cycle throws at module-eval rather than
105
+ // silently mis-granting on the first request. `owner` holds the full set
106
+ // including operator management plus the `*` root scope (so it grants the
107
+ // owner-only fallback below); `manager` covers catalog / orders / customers
108
+ // / marketing writes; `viewer` is read-only — it holds NO write scope, so
109
+ // every `W`-wrapped route refuses it. The role set is the v1-defensible
110
+ // surface; operators wanting finer-grained custom roles compose
111
+ // lib/operator-roles.js on top.
112
+ //
113
+ // Scope strings use `:` segments (the matcher's wildcard syntax —
114
+ // `b.permissions` rejects `.`-separated scopes). The action→permission map
115
+ // below still names the operator-facing permission in `domain.write` dot
116
+ // form (what an auditor reads in the denial row); `_scopeFor` translates
117
+ // the dotted name to its `:` scope at the single check boundary.
118
+ var _perms = b.permissions.create({
119
+ roles: {
120
+ viewer: [],
121
+ manager: { extends: ["viewer"], permissions: ["catalog:write", "orders:write", "customers:write"] },
122
+ // `*` is the root-greedy scope — it covers every required scope,
123
+ // INCLUDING the owner-only fallback (`owner:only`) that an unmapped
124
+ // mutating action resolves to. A manager never holds `*`, so a new,
125
+ // un-mapped write route is owner-reachable only — fail-closed.
126
+ owner: { extends: ["manager"], permissions: ["settings:write", "operators:manage", "*"] },
127
+ },
119
128
  });
120
129
 
130
+ // Role-existence check (replaces the old ROLE_GRANTS-keyed lookup). The
131
+ // sealed-cookie path falls back to viewer for any role this registry
132
+ // doesn't know.
133
+ function _knownRole(role) { return typeof role === "string" && _perms.has(role); }
134
+
135
+ // The owner-only fallback permission. An action whose prefix is NOT in
136
+ // `_ACTION_PERMISSION` resolves here, so a newly-added W()-route nobody
137
+ // maps requires the owner role rather than the broad (manager-grantable)
138
+ // merchandising write. The dotted name is what the denial audit records;
139
+ // it maps to the `owner:only` scope only `owner` (via `*`) can match.
140
+ var OWNER_ONLY_PERMISSION = "owner.only";
141
+
121
142
  // Map a `W(...)` audit-action's first segment to the permission it
122
143
  // requires. Every mutating admin route is covered; an action whose prefix
123
- // is not listed falls back to `catalog.write` (the broad merchandising
124
- // grant) so a newly-added write route is gated rather than silently open.
144
+ // is not listed FAILS CLOSED to the owner-only fallback (not the broad
145
+ // merchandising write) so a newly-added write route is owner-reachable
146
+ // only until it's mapped to the role that should hold it.
125
147
  var _ACTION_PERMISSION = Object.freeze({
126
148
  // catalog / merchandising / marketing content
127
149
  product: "catalog.write", variant: "catalog.write", price: "catalog.write",
@@ -131,7 +153,7 @@ var _ACTION_PERMISSION = Object.freeze({
131
153
  gift: "catalog.write", preorder: "catalog.write", quantity_discount: "catalog.write",
132
154
  auto_discount: "catalog.write", coupon_policy: "catalog.write",
133
155
  promo_banner: "catalog.write", announcement: "catalog.write", blog: "catalog.write",
134
- suggestion: "catalog.write", sidebar_widget: "catalog.write",
156
+ email_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
135
157
  page: "catalog.write", help: "catalog.write", survey: "catalog.write",
136
158
  hours: "catalog.write", delivery_holiday: "catalog.write",
137
159
  delivery_transit: "catalog.write", tax_rate: "catalog.write",
@@ -153,21 +175,32 @@ var _ACTION_PERMISSION = Object.freeze({
153
175
  operator: "operators.manage",
154
176
  });
155
177
 
178
+ // Translate an operator-facing `domain.write` permission name to the
179
+ // `b.permissions` `:` scope the registry matches on. The owner-only
180
+ // fallback maps to `owner:only` — a scope only the `*`-holding owner role
181
+ // covers.
182
+ function _scopeFor(permission) {
183
+ if (permission === OWNER_ONLY_PERMISSION) return "owner:only";
184
+ return permission.replace(/\./g, ":");
185
+ }
186
+
156
187
  // Permission a `W(auditAction, ...)` route requires, derived from the
157
- // action's first dotted segment. Unmapped prefixes default to the broad
158
- // merchandising write so an un-mapped new route fails closed for viewers.
188
+ // action's first dotted segment. Unmapped prefixes FAIL CLOSED to the
189
+ // owner-only fallback so an un-mapped new route can't be reached by a
190
+ // manager — it requires the owner role (or an explicit map entry).
159
191
  function _permissionForAction(auditAction) {
160
- if (typeof auditAction !== "string" || !auditAction.length) return "catalog.write";
192
+ if (typeof auditAction !== "string" || !auditAction.length) return OWNER_ONLY_PERMISSION;
161
193
  var seg = auditAction.split(".")[0];
162
- return _ACTION_PERMISSION[seg] || "catalog.write";
194
+ return _ACTION_PERMISSION[seg] || OWNER_ONLY_PERMISSION;
163
195
  }
164
196
 
165
- // True when `role` grants `permission`. The owner role always grants
166
- // everything; an unknown role grants nothing (fails closed).
197
+ // True when `role` grants `permission`. Delegates to the `b.permissions`
198
+ // registry: the owner role holds `*` (grants everything including the
199
+ // owner-only fallback), manager inherits viewer + its three writes, viewer
200
+ // holds nothing, and an unknown role grants nothing (fails closed).
167
201
  function _roleGrants(role, permission) {
168
- var grants = ROLE_GRANTS[role];
169
- if (!grants) return false;
170
- return grants.indexOf(permission) !== -1;
202
+ if (!_knownRole(role)) return false;
203
+ return _perms.check({ roles: [role] }, _scopeFor(permission));
171
204
  }
172
205
 
173
206
  // Per-request store for the double-submit CSRF token. The admin console is
@@ -610,7 +643,7 @@ async function _resolveActor(req, authCtx) {
610
643
  // No accounts handle wired but the cookie names an operator — treat the
611
644
  // sealed claim's role as authoritative (the cookie is vault-sealed, so it
612
645
  // can't be forged); fail closed to viewer if the role is unexpected.
613
- var role = ROLE_GRANTS[claims.role] ? claims.role : "viewer";
646
+ var role = _knownRole(claims.role) ? claims.role : "viewer";
614
647
  return { kind: "operator", operator_id: String(claims.operator_id), role: role, via: "operator_cookie" };
615
648
  }
616
649
 
@@ -14186,16 +14219,21 @@ function renderAdminConfirm(opts) {
14186
14219
  // 403 page shown when a signed-in operator's role does not grant the
14187
14220
  // permission a browser-form mutation requires. The required permission is
14188
14221
  // stated plainly so the operator knows what to ask the owner for. `perm`
14189
- // is one of the closed OPERATOR_PERMISSIONS tokens (never untrusted), but
14190
- // it is escaped anyway to keep the render escape-by-default.
14222
+ // is one of the closed `_ACTION_PERMISSION` tokens or the owner-only
14223
+ // fallback (never untrusted), but it is escaped anyway to keep the render
14224
+ // escape-by-default.
14191
14225
  function _renderAdminForbidden(shopName, navAvailable, perm) {
14192
14226
  var name = shopName || "blamejs.shop";
14227
+ // The owner-only fallback has no operator-friendly grant name — an
14228
+ // un-mapped action is owner-reachable only — so render its requirement
14229
+ // as "owner" rather than the internal `owner.only` token.
14230
+ var permLabel = perm === OWNER_ONLY_PERMISSION ? "owner" : (perm || "");
14193
14231
  var body =
14194
14232
  "<section class=\"mw-42\">" +
14195
14233
  "<h2>Not permitted</h2>" +
14196
14234
  "<div class=\"banner banner--err\">Your role does not permit this action.</div>" +
14197
14235
  "<div class=\"panel\">" +
14198
- "<p>This action requires the <code>" + _htmlEscape(perm || "") + "</code> permission. " +
14236
+ "<p>This action requires the <code>" + _htmlEscape(permLabel) + "</code> permission. " +
14199
14237
  "Ask an owner to grant your account a role that includes it.</p>" +
14200
14238
  "<a class=\"btn btn--ghost\" href=\"/admin\">Back to dashboard</a>" +
14201
14239
  "</div>" +
@@ -16763,6 +16801,23 @@ function _dsrPillClass(status) {
16763
16801
  // Operator queue. Status chips filter the board; each row links to the
16764
16802
  // request detail. Renders the customer (short id), kind, jurisdiction,
16765
16803
  // scope, status pill, and when it was requested.
16804
+ // Render the statutory-deadline cell for a DSR row. The deadline is the
16805
+ // derived `statutory_deadline` the primitive computes from jurisdiction +
16806
+ // requested_at (GDPR one month, CCPA 45 days, LGPD 15 days; null for a
16807
+ // jurisdiction with no statutory clock). For an OPEN request (received /
16808
+ // processing) an elapsed wall is flagged "overdue"; a closed request shows
16809
+ // the date plainly (the clock no longer runs against the controller).
16810
+ function _dsrDeadlineCell(r) {
16811
+ var d = r && r.statutory_deadline;
16812
+ if (!d || typeof d.due_by !== "number") return "<span class=\"meta\">—</span>";
16813
+ var dateStr = _htmlEscape(_fmtDate(d.due_by));
16814
+ var open = r.status === "received" || r.status === "processing";
16815
+ if (open && Date.now() > d.due_by) {
16816
+ return "<span class=\"status-pill cancelled\" title=\"" + _htmlEscape(d.statute) + "\">overdue · " + dateStr + "</span>";
16817
+ }
16818
+ return "<span title=\"" + _htmlEscape(d.statute) + "\">" + dateStr + "</span>";
16819
+ }
16820
+
16766
16821
  function renderAdminDsr(opts) {
16767
16822
  opts = opts || {};
16768
16823
  var requests = opts.requests || [];
@@ -16783,11 +16838,12 @@ function renderAdminDsr(opts) {
16783
16838
  "<td>" + _htmlEscape(r.scope || "—") + "</td>" +
16784
16839
  "<td><span class=\"status-pill " + _dsrPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></td>" +
16785
16840
  "<td>" + _htmlEscape(_fmtDate(r.requested_at)) + "</td>" +
16841
+ "<td>" + _dsrDeadlineCell(r) + "</td>" +
16786
16842
  "</tr>";
16787
16843
  }).join("");
16788
16844
 
16789
16845
  var table = requests.length
16790
- ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Customer</th><th scope=\"col\">Kind</th><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Scope</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
16846
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Customer</th><th scope=\"col\">Kind</th><th scope=\"col\">Jurisdiction</th><th scope=\"col\">Scope</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th><th scope=\"col\">Statutory deadline</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
16791
16847
  : "<p class=\"empty\">No “" + _htmlEscape(active) + "” privacy requests.</p>";
16792
16848
 
16793
16849
  var body = "<section><h2>Privacy requests</h2>" +
@@ -16901,6 +16957,9 @@ function renderAdminDsrDetail(opts) {
16901
16957
  "<div class=\"two-col\">" +
16902
16958
  "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
16903
16959
  _field("Jurisdiction", r.jurisdiction) +
16960
+ _field("Statutory deadline", r.statutory_deadline
16961
+ ? _fmtDate(r.statutory_deadline.due_by) + " (" + r.statutory_deadline.statute + ")"
16962
+ : null) +
16904
16963
  _field("Scope", r.scope) +
16905
16964
  _field("Reason", r.reason) +
16906
16965
  _field("Dismiss reason", r.dismiss_reason) +
@@ -22856,6 +22915,13 @@ function renderAdminProduct(opts) {
22856
22915
  module.exports = {
22857
22916
  mount: mount,
22858
22917
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
22918
+ // RBAC introspection — the action→permission resolver + the role-grant
22919
+ // predicate the `_wrap` chokepoint uses, surfaced so a test can pin the
22920
+ // fail-closed boundary (an unmapped mutating action is owner-only) without
22921
+ // standing up a live route for every possible un-mapped prefix.
22922
+ _permissionForAction: _permissionForAction,
22923
+ _roleGrants: _roleGrants,
22924
+ _OWNER_ONLY_PERMISSION: OWNER_ONLY_PERMISSION,
22859
22925
  renderDashboard: renderDashboard,
22860
22926
  renderAdminAnalytics: renderAdminAnalytics,
22861
22927
  renderAdminLogin: renderAdminLogin,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.24",
2
+ "version": "0.4.25",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -115,6 +115,48 @@ var STATUSES = Object.freeze([
115
115
  "received", "processing", "fulfilled", "delivered", "dismissed",
116
116
  ]);
117
117
 
118
+ // Statutory response window per jurisdiction — the clock a supervisory
119
+ // authority measures the controller against once a subject files the
120
+ // request. The deadline is `requested_at + days`. GDPR Art. 12(3): one
121
+ // month from receipt (encoded as 30 days — the controller-defensible
122
+ // reading; extendable by two further months for complex requests, which an
123
+ // operator records out of band). CCPA Cal. Civ. Code §1798.130(a)(2): 45
124
+ // days (one 45-day extension permitted). LGPD Art. 19 §II / §3: 15 days for
125
+ // the full declaration. `other` carries no statutory clock — the operator's
126
+ // own SLA governs, so no deadline is surfaced rather than inventing one.
127
+ //
128
+ // This is the DSR-response analogue of b.breach.deadline (which encodes the
129
+ // US-state breach-NOTIFICATION statutes — a different clock with different
130
+ // citations); a subject-access response window has no entry in that
131
+ // registry, so the per-jurisdiction window lives here keyed to the same
132
+ // jurisdiction vocabulary the request rows already carry.
133
+ var DSR_RESPONSE_WINDOW = Object.freeze({
134
+ gdpr: Object.freeze({ days: 30, statute: "GDPR Art. 12(3) (one month from receipt)" }),
135
+ ccpa: Object.freeze({ days: 45, statute: "Cal. Civ. Code §1798.130(a)(2)" }),
136
+ lgpd: Object.freeze({ days: 15, statute: "LGPD Art. 19 §II" }),
137
+ other: null, // operator SLA governs — no statutory clock to surface
138
+ });
139
+
140
+ var MS_PER_DAY = b.constants.TIME.days(1);
141
+
142
+ // Compute the statutory response deadline for a DSR request. Returns null
143
+ // for a jurisdiction with no statutory clock (`other`) or a non-finite
144
+ // requested-at. The shape mirrors b.breach.deadline.forStates entries —
145
+ // `{ jurisdiction, days, due_by, statute }` — so a future operator clock
146
+ // (b.breach.deadline.createClock-style escalation) can adapt it without a
147
+ // reshape.
148
+ function _statutoryDeadline(jurisdiction, requestedAtMs) {
149
+ var win = DSR_RESPONSE_WINDOW[jurisdiction];
150
+ if (!win) return null;
151
+ if (typeof requestedAtMs !== "number" || !isFinite(requestedAtMs)) return null;
152
+ return {
153
+ jurisdiction: jurisdiction,
154
+ days: win.days,
155
+ due_by: requestedAtMs + (win.days * MS_PER_DAY),
156
+ statute: win.statute,
157
+ };
158
+ }
159
+
118
160
  var MAX_REASON_LEN = 4000;
119
161
  var MAX_DISMISS_REASON_LEN = 4000;
120
162
  var MAX_DELIVERY_METHOD_LEN = 64;
@@ -264,6 +306,7 @@ function _isEmptySection(section) {
264
306
 
265
307
  function _hydrate(r) {
266
308
  if (!r) return null;
309
+ var requestedAt = Number(r.requested_at);
267
310
  return {
268
311
  id: r.id,
269
312
  customer_id: r.customer_id,
@@ -272,13 +315,19 @@ function _hydrate(r) {
272
315
  scope: r.scope == null ? null : r.scope,
273
316
  status: r.status,
274
317
  requested_by: r.requested_by,
275
- requested_at: Number(r.requested_at),
318
+ requested_at: requestedAt,
276
319
  fulfilled_at: r.fulfilled_at == null ? null : Number(r.fulfilled_at),
277
320
  delivered_at: r.delivered_at == null ? null : Number(r.delivered_at),
278
321
  dismiss_reason: r.dismiss_reason == null ? null : r.dismiss_reason,
279
322
  delivery_method: r.delivery_method == null ? null : r.delivery_method,
280
323
  delivery_address: r.delivery_address == null ? null : r.delivery_address,
281
324
  reason: r.reason == null ? null : r.reason,
325
+ // Derived: the statutory response deadline the supervisory authority
326
+ // measures against (computed from jurisdiction + requested_at, never
327
+ // persisted — so it always reflects the current registry). Null for a
328
+ // jurisdiction with no statutory clock. The admin DSR console surfaces
329
+ // `due_by` so an operator sees the wall before it elapses.
330
+ statutory_deadline: _statutoryDeadline(r.jurisdiction, requestedAt),
282
331
  };
283
332
  }
284
333
 
@@ -658,10 +707,15 @@ function create(opts) {
658
707
  }
659
708
 
660
709
  module.exports = {
661
- create: create,
662
- REQUEST_KINDS: REQUEST_KINDS,
663
- JURISDICTIONS: JURISDICTIONS,
664
- SCOPES: SCOPES,
665
- STATUSES: STATUSES,
666
- SCOPE_SECTIONS: SCOPE_SECTIONS,
710
+ create: create,
711
+ REQUEST_KINDS: REQUEST_KINDS,
712
+ JURISDICTIONS: JURISDICTIONS,
713
+ SCOPES: SCOPES,
714
+ STATUSES: STATUSES,
715
+ SCOPE_SECTIONS: SCOPE_SECTIONS,
716
+ // DSR statutory response-window registry + the per-request deadline
717
+ // calculator (the console reads the calculator's `due_by`; tests pin the
718
+ // per-jurisdiction windows).
719
+ DSR_RESPONSE_WINDOW: DSR_RESPONSE_WINDOW,
720
+ statutoryDeadline: _statutoryDeadline,
667
721
  };
package/lib/payment.js CHANGED
@@ -177,6 +177,56 @@ function _formEncode(obj, prefix) {
177
177
  return parts.filter(Boolean).join("&");
178
178
  }
179
179
 
180
+ // ---- egress hardening + outbound idempotency-key validation ----------------
181
+ //
182
+ // b.httpClient already routes every dial through b.ssrfGuard (private /
183
+ // loopback / link-local / reserved / cloud-metadata IP classes refused) and
184
+ // pins the TCP connect to the guard's resolved IP set even on the PSP path's
185
+ // caller-supplied TLS agent, so DNS rebinding can't flip the answer between
186
+ // check and connect. The remaining gap is a HOST allowlist: nothing today
187
+ // stops an `opts.apiBase` pointed at an unexpected public host (config
188
+ // injection, or a future code path that derives the base from request data)
189
+ // from reaching a non-PSP upstream. We pin `allowedHosts` to the configured
190
+ // PSP host on every dial so a compromised process can only ever talk to the
191
+ // host the adapter was constructed against — defense-in-depth layered on top
192
+ // of the IP-class SSRF gate.
193
+
194
+ // Extract the lowercase hostname from a PSP base URL. Throws a TypeError on a
195
+ // non-string / non-https / hostless base — config-time validation (entry
196
+ // point), so an operator's typo'd apiBase surfaces at adapter construction
197
+ // rather than on the first charge.
198
+ function _pspHost(baseUrl, label) {
199
+ if (typeof baseUrl !== "string" || !baseUrl.length) {
200
+ throw new TypeError("payment: " + label + " must be a non-empty URL string");
201
+ }
202
+ var parsed;
203
+ try { parsed = new URL(baseUrl); } catch (_e) {
204
+ throw new TypeError("payment: " + label + " must be a valid absolute URL (got " + JSON.stringify(baseUrl) + ")");
205
+ }
206
+ if (parsed.protocol !== "https:") {
207
+ throw new TypeError("payment: " + label + " must be https (a payment processor is never dialed over plaintext)");
208
+ }
209
+ if (!parsed.hostname) {
210
+ throw new TypeError("payment: " + label + " has no hostname");
211
+ }
212
+ return parsed.hostname.toLowerCase();
213
+ }
214
+
215
+ // Validate + normalize an idempotency key that crosses the wire as an
216
+ // outbound header (Stripe `Idempotency-Key` / PayPal `PayPal-Request-Id`).
217
+ // Composes b.guardIdempotencyKey (strict profile): bounded length, control-
218
+ // char / path-traversal / slash refusal, ASCII-only — so a key carrying a
219
+ // log-injection or traversal shape never reaches the processor or the local
220
+ // replay cache. Returns the validated key; throws TypeError on refusal (the
221
+ // caller's bad-shape path is a clean 400, matching every other field guard).
222
+ function _assertOutboundKey(key, label) {
223
+ try {
224
+ return b.guardIdempotencyKey.validate(key, { profile: "strict" });
225
+ } catch (e) {
226
+ throw new TypeError("payment: " + (label || "idempotency key") + " — " + ((e && e.message) || "invalid idempotency key"));
227
+ }
228
+ }
229
+
180
230
  // ---- webhook verifier -----------------------------------------------------
181
231
  //
182
232
  // Composes b.webhook.verify (alg: "hmac-sha256-stripe") — the
@@ -239,7 +289,12 @@ async function _verifyWebhook(headers, rawBody, secret, opts) {
239
289
  // ---- Stripe API call ------------------------------------------------------
240
290
 
241
291
  async function _stripeCall(opts, method, path, params, idempotencyKey) {
242
- var url = (opts.apiBase || STRIPE_API_BASE_DEFAULT) + path;
292
+ var apiBase = opts.apiBase || STRIPE_API_BASE_DEFAULT;
293
+ var url = apiBase + path;
294
+ // Pin the dial to the configured Stripe host (computed once per adapter).
295
+ // Layered on top of b.httpClient's IP-class SSRF gate — the process can
296
+ // only ever reach the host the adapter was built against.
297
+ if (opts._allowedHost === undefined) opts._allowedHost = _pspHost(apiBase, "apiBase");
243
298
  var headers = {
244
299
  "authorization": "Bearer " + opts.apiKey,
245
300
  "accept": "application/json",
@@ -252,7 +307,9 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
252
307
  headers["content-length"] = Buffer.byteLength(body, "utf8");
253
308
  }
254
309
  if (idempotencyKey) {
255
- headers["idempotency-key"] = idempotencyKey;
310
+ // The key crosses the wire as the `Idempotency-Key` header — validate +
311
+ // refuse traversal / control-char / oversize shapes before it leaves.
312
+ headers["idempotency-key"] = _assertOutboundKey(idempotencyKey, "idempotency_key");
256
313
  }
257
314
  var httpClient = opts.httpClient || b.httpClient;
258
315
  // A GET read is always idempotent; a write is idempotent only when it
@@ -268,12 +325,13 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
268
325
  // acceptable since a sustained stream of 4xx is itself a degraded state.
269
326
  var json = await _dial(opts._breaker, idempotent, async function () {
270
327
  var res = await httpClient.request({
271
- method: method,
272
- url: url,
273
- headers: headers,
274
- body: body || undefined,
275
- timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
276
- agent: _PSP_TLS_AGENT,
328
+ method: method,
329
+ url: url,
330
+ headers: headers,
331
+ body: body || undefined,
332
+ timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
333
+ agent: _PSP_TLS_AGENT,
334
+ allowedHosts: [opts._allowedHost],
277
335
  });
278
336
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
279
337
  var parsed = null;
@@ -709,6 +767,14 @@ function _paypalApiBase(opts) {
709
767
  return opts.sandbox ? PAYPAL_API_BASE_SANDBOX : PAYPAL_API_BASE_LIVE;
710
768
  }
711
769
 
770
+ // The PayPal host the adapter is allowed to dial (computed once, cached on
771
+ // opts). Pins every PayPal dial — token exchange + Orders-v2 — to the
772
+ // configured host, layered on b.httpClient's IP-class SSRF gate.
773
+ function _paypalAllowedHost(opts) {
774
+ if (opts._allowedHost === undefined) opts._allowedHost = _pspHost(_paypalApiBase(opts), "apiBase");
775
+ return opts._allowedHost;
776
+ }
777
+
712
778
  function _minorToDecimalString(minor, currency) {
713
779
  var dec = PAYPAL_ZERO_DECIMAL[currency] ? 0 : 2;
714
780
  var neg = minor < 0;
@@ -745,9 +811,10 @@ async function _paypalToken(opts, state) {
745
811
  "content-type": "application/x-www-form-urlencoded",
746
812
  "user-agent": "blamejs-shop (zero-dep)",
747
813
  },
748
- body: "grant_type=client_credentials",
749
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
750
- agent: _PSP_TLS_AGENT,
814
+ body: "grant_type=client_credentials",
815
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
816
+ agent: _PSP_TLS_AGENT,
817
+ allowedHosts: [_paypalAllowedHost(opts)],
751
818
  });
752
819
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
753
820
  var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = {}; }
@@ -774,10 +841,15 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
774
841
  "content-type": "application/json",
775
842
  "user-agent": "blamejs-shop (zero-dep)",
776
843
  };
777
- if (requestId) headers["paypal-request-id"] = requestId;
844
+ // The request id crosses the wire as the `PayPal-Request-Id` header —
845
+ // validate + refuse traversal / control-char / oversize shapes before it
846
+ // leaves (covers both operator-supplied keys and the adapter-constructed
847
+ // `order:`/`capture:` ids).
848
+ if (requestId) headers["paypal-request-id"] = _assertOutboundKey(requestId, "paypal_request_id");
778
849
  var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
779
850
  if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
780
851
  var httpClient = opts.httpClient || b.httpClient;
852
+ var allowedHost = _paypalAllowedHost(opts);
781
853
  // A GET is idempotent; a write is idempotent only when it carries a
782
854
  // PayPal-Request-Id (PayPal dedupes a replay of the SAME id, and the
783
855
  // same id rides every retry attempt within one call). A keyless write
@@ -785,12 +857,13 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
785
857
  var idempotent = method === "GET" || !!requestId;
786
858
  var json = await _dial(opts._breaker, idempotent, async function () {
787
859
  var res = await httpClient.request({
788
- method: method,
789
- url: _paypalApiBase(opts) + path,
790
- headers: headers,
791
- body: body,
792
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
793
- agent: _PSP_TLS_AGENT,
860
+ method: method,
861
+ url: _paypalApiBase(opts) + path,
862
+ headers: headers,
863
+ body: body,
864
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
865
+ agent: _PSP_TLS_AGENT,
866
+ allowedHosts: [allowedHost],
794
867
  });
795
868
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
796
869
  var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
@@ -608,7 +608,13 @@ function globalRateLimitOpts() {
608
608
  function botGuardOpts() {
609
609
  return {
610
610
  skipPaths: INTERNAL_BRIDGE_PATHS.slice()
611
- .concat(PUBLIC_WELL_KNOWN_PATHS)
611
+ // EXACT-match the well-known paths. A bare-string skip is matched as a
612
+ // PREFIX by the bot guard, which would also exempt any sibling under the
613
+ // directory (e.g. /.well-known/apple-...-association/anything), re-opening
614
+ // the guard on routes that aren't the single static association file.
615
+ .concat(PUBLIC_WELL_KNOWN_PATHS.map(function (p) {
616
+ return new RegExp("^" + p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + "$");
617
+ }))
612
618
  .concat([/^\/admin(\/|$)/]),
613
619
  };
614
620
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
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": {