@blamejs/blamejs-shop 0.3.50 → 0.3.52
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 +4 -0
- package/README.md +10 -1
- package/SECURITY.md +58 -0
- package/lib/admin.js +159 -1
- package/lib/asset-manifest.json +17 -5
- package/lib/security-middleware.js +170 -7
- package/lib/storefront.js +277 -66
- package/lib/vendor/MANIFEST.json +2665 -2
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.52 (2026-06-02) — **Read-only admin audit log, database backup tooling, an email-deliverability guide, and a cryptographic integrity gate over the vendored framework.** Audit events were recorded but had no console view, there was no documented database backup path, no operator guidance for email deliverability, and the vendored framework tree was only checked for presence — not integrity. This release adds a read-only audit log screen in the admin console, a database export script plus point-in-time recovery documentation, an SPF/DKIM/DMARC deliverability guide, and a build gate that cryptographically verifies every file in the vendored framework so a tampered or accidentally edited dependency fails the build. **Added:** *Admin audit log screen* — A read-only Audit screen in the admin console lists recorded audit events — action, outcome, actor, resource, and details — with an outcome filter and pagination. Every field is HTML-escaped. The screen is a pure observability view: if the audit store is briefly unavailable it shows an empty table with a notice rather than erroring. · *Database backup and recovery tooling* — A new export script wraps the D1 export command (suitable for a scheduled export to object storage), and the operator docs gain a recovery section pointing at D1 Time Travel for point-in-time restore. · *Email deliverability operator guide* — SECURITY.md and the README gain an email-deliverability section covering the SPF, DKIM, and DMARC DNS setup that determines whether transactional and cart-recovery mail reaches the inbox, alongside the already-handled one-click unsubscribe and suppression behavior. · *Cryptographic integrity gate over the vendored framework* — The build now re-hashes every file in the vendored framework tree against a pinned manifest and fails if any digest changes, giving the no-hand-edits-to-vendored-code rule real enforcement. The check runs across the test matrix and inside the container image build.
|
|
12
|
+
|
|
13
|
+
- v0.3.51 (2026-06-02) — **Payment scripts load under the strict CSP, an optional CAPTCHA gate guards signup and checkout, HSTS is preload-strength, and order actions are rate-limited.** The strict content-security-policy blocked the Stripe and PayPal scripts on the payment pages, so checkout would have failed even once payment keys were configured. Those pages now load the processor scripts through a route-scoped policy and same-origin script files, without relaxing the site-wide policy anywhere else. A CAPTCHA challenge can now be required on signup and checkout (and optionally login) once an operator configures a provider; it stays off until then. The edge now sends a two-year HSTS max-age so the domain qualifies for the preload list, and the order cancel, rate, and reorder actions are covered by the tighter per-route rate limit. **Added:** *Optional CAPTCHA gate on signup, checkout, and login* — When an operator configures a CAPTCHA provider (Cloudflare Turnstile, hCaptcha, or reCAPTCHA), a challenge is required on signup and checkout, with login behind an opt-in flag since passkey sign-in is already phishing-resistant. The token is verified server-side before the action proceeds. With no provider configured the gate is a no-op and every flow behaves exactly as before. **Changed:** *Preload-strength HSTS at the edge* — The edge now sends an HSTS max-age of two years (with includeSubDomains and preload), matching the container and meeting the one-year minimum required for HSTS preload-list inclusion. **Fixed:** *Payment pages load Stripe and PayPal under the strict CSP* — The pay and checkout pages now load the Stripe and PayPal scripts through a content-security-policy scoped to those routes plus same-origin script files, instead of inline script that the site-wide strict policy blocked. Checkout works once payment keys are set, and the site-wide policy — no inline script, Trusted Types required, restricted object and frame sources — is unchanged everywhere else. · *Order actions covered by the tighter rate limit* — Cancelling, rating, and reordering an order are now under the same strict per-route rate limit as other sensitive actions, rather than only the looser global limit.
|
|
14
|
+
|
|
11
15
|
- v0.3.50 (2026-06-02) — **Installable PWA manifest, hreflang for multilingual stores, complete sitemap and robots coverage, localized edge pages, and product Q&A rich results.** Several search-engine and internationalization signals were missing or inconsistent between the cached (edge) and dynamically rendered pages. This release serves an installable web-app manifest and a service worker, emits hreflang alternates when a store runs path- or subdomain-based locales, localizes the blog, CMS, and policy pages on the cached path (they were always English), brings the robots.txt and sitemap into agreement across both render paths with collections, categories, and CMS pages now included, and emits Q&A structured data on product pages. It also fixes a bug that dropped every product from the dynamically rendered sitemap. **Added:** *Installable web-app manifest and service worker* — The store now serves a web-app manifest at /manifest.webmanifest and a service worker at /sw.js, and links the manifest from every page, so the storefront can be installed as a progressive web app. A working default is served out of the box; an operator who configures a custom manifest overrides it. · *hreflang alternates for multilingual stores* — When a store runs locales via URL prefixes (e.g. /de/) or subdomains, each page now emits rel=alternate hreflang links (plus x-default) so search engines map the equivalent localized URLs instead of treating them as duplicate content. Stores on a single locale, or locale-by-cookie, emit none. · *Product Q&A rich results* — A product page with answered customer questions now emits QAPage structured data so the questions and answers are eligible for rich results in search. **Fixed:** *Localized blog, CMS, and policy pages on the cached path* — The cached (edge-rendered) blog articles, CMS pages, and privacy/terms pages now carry the correct document language and direction for the active locale, matching the dynamically rendered pages. Previously they were always marked English and left-to-right, which harmed screen readers and search indexing and broke right-to-left layout. · *Consistent robots.txt and complete sitemap* — robots.txt now disallows the admin, cart, checkout, order, and account paths consistently on both render paths, and the sitemap now includes collection, category, and CMS-page URLs alongside products and blog posts. A bug that made the dynamically rendered sitemap omit every product (a list limit overflow) is fixed so the full catalog is listed.
|
|
12
16
|
|
|
13
17
|
- v0.3.49 (2026-06-02) — **Real collection tiles on the home page, a working mobile nav, Collections/Categories in the header, and From-price on multi-variant products.** The home page's 'Featured collections' band linked to fixed keyword searches that landed on empty results unless a product happened to contain those words, the header navigation disappeared entirely on phones with no menu to replace it, and the two real catalog-browse surfaces (Collections and Categories) were reachable only from the footer. This release resolves those: the home tiles now render the store's actual collections with real links, the header gains a no-JavaScript mobile menu, and Collections and Categories appear in the primary nav. Multi-variant products now show a 'From <lowest price>' headline instead of always showing the first variant's price, the non-functional 'New arrivals'/'On sale' sort links are removed, and two small accessibility fixes land (the survey required-field marker and a dark color-scheme hint for native form controls). **Changed:** *Home 'Featured collections' tiles render real collections* — The featured-collections band on the home page now lists the store's actual collections with links to each collection page, instead of fixed keyword searches that could land on an empty results page. If no collections are defined, the band is omitted rather than shown empty. Both the cached and dynamically rendered home pages produce identical markup. · *Mobile navigation menu (no JavaScript required)* — On narrow screens the header now offers a disclosure menu that reveals the navigation links, which previously vanished entirely below a tablet width. It is a pure-CSS menu, so it works with JavaScript disabled. · *Collections and Categories in the header navigation* — The two catalog-browse surfaces — Collections and Categories — are now in the primary header navigation (and inside the mobile menu), not only in the footer. · *Multi-variant products show a 'From' price* — A product whose variants differ in price now shows a 'From <lowest price>' headline so the prominent price is no longer misleading when a shopper selects a different variant; each variant chip still shows its own exact price. · *Removed non-functional sort links* — The 'New arrivals' and 'On sale' links did nothing — the home page does not yet support sorting — so they have been removed rather than left as dead controls. They will return if and when catalog sorting is added. · *Accessibility: survey required marker and dark color-scheme* — The survey form's required-field marker is now announced correctly to screen readers, and the theme declares a dark color-scheme so native form controls, scrollbars, and autofill render correctly on the dark surface.
|
package/README.md
CHANGED
|
@@ -45,6 +45,15 @@ node test/smoke.js
|
|
|
45
45
|
- **`b.externalDb` adapter for Cloudflare D1** (`lib/externaldb-d1.js`) — service-binding + REST-API modes, normalized result envelope, AbortController timeouts, jittered retry on transient errors.
|
|
46
46
|
- **`InventoryLock` Durable Object** — per-SKU serialization point so concurrent checkouts across container replicas can't oversell.
|
|
47
47
|
- **`docs/deploy-cloudflare.md`** — operator deploy recipe end-to-end.
|
|
48
|
+
- **Database backup & recovery** — D1 Time Travel gives 30 days of
|
|
49
|
+
always-on point-in-time recovery; `npm run d1-export` (`scripts/d1-export.js`)
|
|
50
|
+
writes a full logical `.sql` dump and can push it to a private R2 `backups/`
|
|
51
|
+
key. See SECURITY.md → Database backup & recovery.
|
|
52
|
+
- **Email deliverability** (SPF / DKIM / DMARC + one-click List-Unsubscribe):
|
|
53
|
+
see SECURITY.md → Email deliverability for the DNS records to publish.
|
|
54
|
+
- **Supply-chain integrity** — the build verifies every vendored file's
|
|
55
|
+
SHA-256 against `lib/vendor/MANIFEST.json`, so a hand-edit to the vendored
|
|
56
|
+
tree fails smoke. See SECURITY.md → Supply-chain integrity.
|
|
48
57
|
|
|
49
58
|
### Commerce primitives
|
|
50
59
|
|
|
@@ -87,7 +96,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
87
96
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
88
97
|
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
|
89
98
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
90
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
99
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
91
100
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
92
101
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
93
102
|
|
package/SECURITY.md
CHANGED
|
@@ -198,3 +198,61 @@ node -e "
|
|
|
198
198
|
emails are stored hash-only (`b.crypto.namespaceHash`), and the
|
|
199
199
|
account leaderboard exposes rank plus initials only, never an email or
|
|
200
200
|
account id.
|
|
201
|
+
- **Privileged actions are recorded and reviewable.** Every mutating
|
|
202
|
+
admin action and every catalog-API error is appended to a
|
|
203
|
+
tamper-evident, hash-chained audit log (the framework's `b.audit`
|
|
204
|
+
surface — append-only, verified at boot). Operators review it at
|
|
205
|
+
`/admin/audit`: a read-only activity log filterable by outcome
|
|
206
|
+
(success / failure / denied) and paginated. Opening the log is itself
|
|
207
|
+
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
208
|
+
its own forensic mark.
|
|
209
|
+
- **Email deliverability — SPF / DKIM / DMARC + one-click unsubscribe.**
|
|
210
|
+
Transactional and broadcast mail is composed on `b.mail`, which signs
|
|
211
|
+
each message with DKIM, but the receiving server still rejects or
|
|
212
|
+
spam-folders mail whose envelope DNS isn't published. Publish all
|
|
213
|
+
three records for the From: domain:
|
|
214
|
+
- **SPF:** a TXT record on the sending domain authorizing the
|
|
215
|
+
service that emits the envelope (e.g. `v=spf1
|
|
216
|
+
include:<your-relay> -all`). `b.mail` signs the body but does not
|
|
217
|
+
control the envelope sender, so SPF must name whatever relay
|
|
218
|
+
actually delivers.
|
|
219
|
+
- **DKIM:** `b.mail.dkim` generates the `DKIM-Signature` header;
|
|
220
|
+
publish the matching public key as a TXT record at
|
|
221
|
+
`<selector>._domainkey.<domain>`. The selector is the one
|
|
222
|
+
configured for the signing key.
|
|
223
|
+
- **DMARC:** publish `_dmarc.<domain>` with at least
|
|
224
|
+
`v=DMARC1; p=quarantine; rua=mailto:<aggregate-report-mailbox>`.
|
|
225
|
+
Align the SPF and DKIM domains with the From: domain so DMARC
|
|
226
|
+
passes; raise to `p=reject` once aggregate reports are clean.
|
|
227
|
+
- **One-click unsubscribe (RFC 8058):** the broadcast path emits
|
|
228
|
+
`List-Unsubscribe` plus `List-Unsubscribe-Post:
|
|
229
|
+
List-Unsubscribe=One-Click` (composed on `b.guardListUnsubscribe`).
|
|
230
|
+
Keep the unsubscribe endpoint reachable from the public origin —
|
|
231
|
+
mailbox providers fetch it directly, and an unreachable endpoint
|
|
232
|
+
hurts sender reputation.
|
|
233
|
+
- **Database backup & recovery.** Two layers protect the live D1
|
|
234
|
+
database:
|
|
235
|
+
- **D1 Time Travel (always on).** Cloudflare retains a rolling
|
|
236
|
+
30-day point-in-time history of the database with no setup.
|
|
237
|
+
Recover with `wrangler d1 time-travel restore blamejs-shop
|
|
238
|
+
--timestamp <ISO-8601>` (or `--bookmark <id>`); inspect the
|
|
239
|
+
current bookmark with `wrangler d1 time-travel info blamejs-shop`.
|
|
240
|
+
- **Scheduled logical export.** `node scripts/d1-export.js --to-r2`
|
|
241
|
+
(or `npm run d1-export -- --to-r2`) produces a full `.sql` dump of
|
|
242
|
+
the live database and uploads it to the private `backups/` key in
|
|
243
|
+
the assets R2 bucket. Schedule it from the operator's own
|
|
244
|
+
cron / CI on whatever cadence the retention policy requires; it is
|
|
245
|
+
not wired into the deploy. The dump bears all customer data —
|
|
246
|
+
treat the R2 `backups/` prefix as sensitive. It is NOT on the
|
|
247
|
+
Worker's served asset path (the Worker serves only
|
|
248
|
+
`/assets/themes/...` and `brand/`), so a backup object is never
|
|
249
|
+
web-reachable.
|
|
250
|
+
- **Supply-chain integrity — vendored bytes are verified on every
|
|
251
|
+
build.** `lib/vendor/MANIFEST.json` pins a per-file SHA-256 of the
|
|
252
|
+
entire vendored blamejs tree. The smoke gate (run in CI on every
|
|
253
|
+
push/PR and inside the container image build) recomputes each file's
|
|
254
|
+
digest and compares it against the manifest via the framework's own
|
|
255
|
+
`b.configDrift.verifyVendorIntegrity`, so a hand-edit to any vendored
|
|
256
|
+
source — or a vendor refresh that skipped re-stamping — fails the
|
|
257
|
+
build before it can ship. Refresh + re-stamp is a single command
|
|
258
|
+
(`scripts/vendor-update.sh blamejs <tag>`).
|
package/lib/admin.js
CHANGED
|
@@ -515,6 +515,12 @@ function mount(router, deps) {
|
|
|
515
515
|
var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
|
|
516
516
|
var orderRatings = deps.orderRatings || null; // per-order rating moderation queue (flag/clear + public reply) disabled when absent
|
|
517
517
|
var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
|
|
518
|
+
// Read-only activity log at /admin/audit. Defaults ON — the framework
|
|
519
|
+
// audit chain is always booted by createApp, so the screen always has a
|
|
520
|
+
// data source (unlike the optional primitives above, which default off).
|
|
521
|
+
// An operator who wants the console surface minimized can pass
|
|
522
|
+
// `auditLog: false` to hide both the route and the nav link.
|
|
523
|
+
var auditLog = deps.auditLog !== false;
|
|
518
524
|
|
|
519
525
|
// Which optional console sections are wired — gates their nav links so a
|
|
520
526
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
@@ -522,7 +528,7 @@ function mount(router, deps) {
|
|
|
522
528
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
523
529
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
524
530
|
// notice when the salesReports primitive isn't wired.
|
|
525
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
|
|
531
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport, auditLog: auditLog };
|
|
526
532
|
|
|
527
533
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
528
534
|
|
|
@@ -1739,6 +1745,72 @@ function mount(router, deps) {
|
|
|
1739
1745
|
},
|
|
1740
1746
|
));
|
|
1741
1747
|
|
|
1748
|
+
// ---- audit / activity log (read-only) -------------------------------
|
|
1749
|
+
//
|
|
1750
|
+
// A read-only window onto the framework's tamper-evident audit chain —
|
|
1751
|
+
// every mutating admin action (`shop_admin.*`) and catalog-API error is
|
|
1752
|
+
// recorded there. Container-only: it reads `b.audit.query`, which needs
|
|
1753
|
+
// the booted framework DB, so there is no edge twin (and none is needed —
|
|
1754
|
+
// the whole admin console is container-only). Defaults ON; hidden when an
|
|
1755
|
+
// operator passes `auditLog: false`. No POST forms (read-only), so the
|
|
1756
|
+
// shell's CSRF field injection is a no-op here. `b.audit.query` itself
|
|
1757
|
+
// logs an `audit.read` row before each query, so opening this screen is
|
|
1758
|
+
// forensically recorded.
|
|
1759
|
+
//
|
|
1760
|
+
// Request-shape reader → RETURN-DEFAULT discipline: bad query params are
|
|
1761
|
+
// clamped / ignored, never thrown (a dashboard 500 on a fat-fingered URL
|
|
1762
|
+
// is worse than showing all). An unrecognized ?outcome= falls back to
|
|
1763
|
+
// no-filter with a notice, mirroring the orders bad-filter fallback.
|
|
1764
|
+
var AUDIT_OUTCOMES = ["success", "failure", "denied"];
|
|
1765
|
+
function _parseAuditQuery(req) {
|
|
1766
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1767
|
+
var q = {};
|
|
1768
|
+
var notice = null;
|
|
1769
|
+
var outcome = url && url.searchParams.get("outcome");
|
|
1770
|
+
if (outcome) {
|
|
1771
|
+
if (AUDIT_OUTCOMES.indexOf(outcome) !== -1) q.outcome = outcome;
|
|
1772
|
+
else notice = "Unknown outcome filter — showing all.";
|
|
1773
|
+
}
|
|
1774
|
+
var action = url && url.searchParams.get("action");
|
|
1775
|
+
if (typeof action === "string" && action.length) q.action = action.slice(0, 200);
|
|
1776
|
+
var actorUserId = url && url.searchParams.get("actorUserId");
|
|
1777
|
+
if (typeof actorUserId === "string" && actorUserId.length) q.actorUserId = actorUserId.slice(0, 200);
|
|
1778
|
+
// limit: clamp to 1..200, default 50. offset: >= 0, default 0.
|
|
1779
|
+
var limitN = parseInt((url && url.searchParams.get("limit")) || "", 10);
|
|
1780
|
+
q.limit = (Number.isFinite(limitN) && limitN >= 1 && limitN <= 200) ? limitN : 50;
|
|
1781
|
+
var offsetN = parseInt((url && url.searchParams.get("offset")) || "", 10);
|
|
1782
|
+
q.offset = (Number.isFinite(offsetN) && offsetN >= 0) ? offsetN : 0;
|
|
1783
|
+
q._page = Math.floor(q.offset / q.limit) + 1;
|
|
1784
|
+
q._notice = notice;
|
|
1785
|
+
return q;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
if (auditLog) {
|
|
1789
|
+
router.get("/admin/audit", _pageOrApi(true,
|
|
1790
|
+
R(async function (req, res) { // bearer → JSON
|
|
1791
|
+
var q = _parseAuditQuery(req);
|
|
1792
|
+
var rows = [];
|
|
1793
|
+
try { rows = await b.audit.query(q); }
|
|
1794
|
+
catch (_e) { rows = []; }
|
|
1795
|
+
_json(res, 200, { rows: rows, page: q._page, limit: q.limit });
|
|
1796
|
+
}),
|
|
1797
|
+
async function (req, res) { // browser cookie → HTML
|
|
1798
|
+
var q = _parseAuditQuery(req);
|
|
1799
|
+
var rows = [];
|
|
1800
|
+
var notice = q._notice;
|
|
1801
|
+
// Read-only observability view: ANY query failure (a malformed
|
|
1802
|
+
// criterion, or an unavailable audit store) degrades to an empty
|
|
1803
|
+
// table + notice, never a 500. Drop-silent by design.
|
|
1804
|
+
try { rows = await b.audit.query(q); }
|
|
1805
|
+
catch (_e) { rows = []; notice = notice || "The audit log is temporarily unavailable."; }
|
|
1806
|
+
_sendHtml(res, 200, renderAdminAudit({
|
|
1807
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1808
|
+
rows: rows, filters: q, notice: notice,
|
|
1809
|
+
}));
|
|
1810
|
+
},
|
|
1811
|
+
));
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1742
1814
|
// ---- shipment tracking (operator-managed) ---------------------------
|
|
1743
1815
|
//
|
|
1744
1816
|
// Attach a shipment to an order (carrier + optional tracking number) and
|
|
@@ -9901,6 +9973,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
9901
9973
|
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
9902
9974
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
9903
9975
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
9976
|
+
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
9904
9977
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
9905
9978
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
9906
9979
|
{ key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
|
|
@@ -10212,6 +10285,90 @@ function renderAdminOrders(opts) {
|
|
|
10212
10285
|
return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
|
|
10213
10286
|
}
|
|
10214
10287
|
|
|
10288
|
+
// The outcomes an operator can filter the audit log by — drives the chips.
|
|
10289
|
+
var AUDIT_OUTCOME_FILTERS = ["success", "failure", "denied"];
|
|
10290
|
+
|
|
10291
|
+
// Read-only activity log. Renders the framework audit rows (action, outcome,
|
|
10292
|
+
// actor, resource, truncated metadata) for the selected outcome filter, with
|
|
10293
|
+
// offset/limit paging.
|
|
10294
|
+
//
|
|
10295
|
+
// XSS-CRITICAL: audit rows carry operator-controlled action names and
|
|
10296
|
+
// ATTACKER-INFLUENCED free text — a bot-driven `actorUserAgent`, a caught
|
|
10297
|
+
// error's message folded into `metadata`, an attacker-chosen resource id.
|
|
10298
|
+
// EVERY cell value flows through `_htmlEscape` (escape-by-default). This
|
|
10299
|
+
// class is un-lintable (no detector can tell a render-concat apart from a
|
|
10300
|
+
// string-build), so the XSS-payload regression test is the guarantee — see
|
|
10301
|
+
// test/layer-2-integration/admin-audit-console.test.js.
|
|
10302
|
+
function renderAdminAudit(opts) {
|
|
10303
|
+
opts = opts || {};
|
|
10304
|
+
var rows = opts.rows || [];
|
|
10305
|
+
var filters = opts.filters || {};
|
|
10306
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
10307
|
+
var active = filters.outcome || null;
|
|
10308
|
+
var limit = filters.limit || 50;
|
|
10309
|
+
var offset = filters.offset || 0;
|
|
10310
|
+
|
|
10311
|
+
// Preserve an active ?action= filter across the outcome chips + pager.
|
|
10312
|
+
function _withParams(extra) {
|
|
10313
|
+
var parts = [];
|
|
10314
|
+
if (extra.outcome) parts.push("outcome=" + encodeURIComponent(extra.outcome));
|
|
10315
|
+
if (filters.action) parts.push("action=" + encodeURIComponent(filters.action));
|
|
10316
|
+
if (filters.actorUserId) parts.push("actorUserId=" + encodeURIComponent(filters.actorUserId));
|
|
10317
|
+
if (extra.offset != null) parts.push("offset=" + encodeURIComponent(String(extra.offset)));
|
|
10318
|
+
if (limit !== 50) parts.push("limit=" + encodeURIComponent(String(limit)));
|
|
10319
|
+
return "/admin/audit" + (parts.length ? "?" + parts.join("&") : "");
|
|
10320
|
+
}
|
|
10321
|
+
|
|
10322
|
+
var chips = "<div class=\"order-filters\">" +
|
|
10323
|
+
"<a class=\"chip" + (active ? "" : " chip--on") + "\" href=\"" + _htmlEscape(_withParams({})) + "\">All</a>" +
|
|
10324
|
+
AUDIT_OUTCOME_FILTERS.map(function (o) {
|
|
10325
|
+
return "<a class=\"chip" + (active === o ? " chip--on" : "") + "\" href=\"" + _htmlEscape(_withParams({ outcome: o })) + "\">" + _htmlEscape(o) + "</a>";
|
|
10326
|
+
}).join("") +
|
|
10327
|
+
"</div>";
|
|
10328
|
+
|
|
10329
|
+
var bodyRows = rows.map(function (row) {
|
|
10330
|
+
// metadata is a JSON string (audit.record stores it as JSON.stringify);
|
|
10331
|
+
// escape THEN truncate so a long redacted blob can't blow the row, and
|
|
10332
|
+
// so a payload inside it is neutralized before it reaches the DOM.
|
|
10333
|
+
var meta = row.metadata == null ? "" : String(row.metadata);
|
|
10334
|
+
var metaEsc = _htmlEscape(meta.length > 120 ? meta.slice(0, 120) + "…" : meta);
|
|
10335
|
+
var resource = "";
|
|
10336
|
+
if (row.resourceKind || row.resourceId) {
|
|
10337
|
+
resource = _htmlEscape(row.resourceKind || "") +
|
|
10338
|
+
(row.resourceId ? " <code>" + _htmlEscape(row.resourceId) + "</code>" : "");
|
|
10339
|
+
} else {
|
|
10340
|
+
resource = "—";
|
|
10341
|
+
}
|
|
10342
|
+
return "<tr>" +
|
|
10343
|
+
"<td>" + _htmlEscape(_fmtDate(row.recordedAt)) + "</td>" +
|
|
10344
|
+
"<td><code>" + _htmlEscape(row.action) + "</code></td>" +
|
|
10345
|
+
"<td><span class=\"status-pill " + _htmlEscape(row.outcome) + "\">" + _htmlEscape(row.outcome) + "</span></td>" +
|
|
10346
|
+
"<td>" + _htmlEscape(row.actorUserId == null ? "—" : row.actorUserId) + "</td>" +
|
|
10347
|
+
"<td>" + resource + "</td>" +
|
|
10348
|
+
"<td class=\"audit-meta\">" + metaEsc + "</td>" +
|
|
10349
|
+
"</tr>";
|
|
10350
|
+
}).join("");
|
|
10351
|
+
|
|
10352
|
+
var table = rows.length
|
|
10353
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Time</th><th scope=\"col\">Action</th><th scope=\"col\">Outcome</th><th scope=\"col\">Actor</th><th scope=\"col\">Resource</th><th scope=\"col\">Details</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
10354
|
+
: "<p class=\"empty\">No audit events match.</p>";
|
|
10355
|
+
|
|
10356
|
+
// Pager: Newer steps back one page (disabled at offset 0); Older steps
|
|
10357
|
+
// forward (shown only when the page is full — the standard "there may be
|
|
10358
|
+
// more" heuristic). Filters are preserved in both hrefs.
|
|
10359
|
+
var prevOffset = Math.max(0, offset - limit);
|
|
10360
|
+
var newer = offset > 0
|
|
10361
|
+
? "<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(_withParams({ outcome: active, offset: prevOffset })) + "\">← Newer</a>"
|
|
10362
|
+
: "<span class=\"btn btn--ghost\" aria-disabled=\"true\">← Newer</span>";
|
|
10363
|
+
var older = rows.length === limit
|
|
10364
|
+
? "<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(_withParams({ outcome: active, offset: offset + limit })) + "\">Older →</a>"
|
|
10365
|
+
: "";
|
|
10366
|
+
var pager = "<div class=\"order-filters\">" + newer + older + "</div>";
|
|
10367
|
+
|
|
10368
|
+
var body = "<section><h2>Audit log</h2>" + notice + chips + table + (rows.length || offset > 0 ? pager : "") + "</section>";
|
|
10369
|
+
return _renderAdminShell(opts.shop_name, "Audit", body, "audit", opts.nav_available);
|
|
10370
|
+
}
|
|
10371
|
+
|
|
10215
10372
|
// epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
|
|
10216
10373
|
// guard so a bad value never throws inside the template.
|
|
10217
10374
|
function _dateInputValue(v) {
|
|
@@ -15969,4 +16126,5 @@ module.exports = {
|
|
|
15969
16126
|
renderAdminLoyaltyRewards: renderAdminLoyaltyRewards,
|
|
15970
16127
|
renderAdminLoyaltyReward: renderAdminLoyaltyReward,
|
|
15971
16128
|
renderAdminConfirm: renderAdminConfirm,
|
|
16129
|
+
renderAdminAudit: renderAdminAudit,
|
|
15972
16130
|
};
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
2
|
+
"version": "0.3.52",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-/NizdENQkTc96romD0kmnl8220NAvW32QI0aSFuGiPbWE5lYbRxLXU5shsjlx5Ek",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
|
14
14
|
"fingerprinted": "js/announcement.59eab4872d2f3b4c.js"
|
|
15
15
|
},
|
|
16
|
+
"js/captcha.js": {
|
|
17
|
+
"integrity": "sha384-gsgNDf7cAOxXuZw5Fat4HiRUPCjKoU22W+3AIjNOx2l+i4lJXIHHNJdsXgarm3vy",
|
|
18
|
+
"fingerprinted": "js/captcha.2aab5803642f8ac5.js"
|
|
19
|
+
},
|
|
16
20
|
"js/cart-count.js": {
|
|
17
21
|
"integrity": "sha384-K/rkm//Dzg8nuOfpaeenJvLKKl+6DEuvuJi1LLgd46BK+dd1HYmU8R7/gHHjtEsr",
|
|
18
22
|
"fingerprinted": "js/cart-count.bfc2eb434b19c00a.js"
|
|
@@ -26,12 +30,20 @@
|
|
|
26
30
|
"fingerprinted": "js/passkey-add.b535e6a3eef4514e.js"
|
|
27
31
|
},
|
|
28
32
|
"js/passkey-login.js": {
|
|
29
|
-
"integrity": "sha384-
|
|
30
|
-
"fingerprinted": "js/passkey-login.
|
|
33
|
+
"integrity": "sha384-AbMxT4s0paFnZsEfAHfcpbS2ZRSmZ9KPnttEBy9SvWErBX3U+LhoeYYN7Nm3Y8AX",
|
|
34
|
+
"fingerprinted": "js/passkey-login.90c13d7c0d8667e2.js"
|
|
31
35
|
},
|
|
32
36
|
"js/passkey-register.js": {
|
|
33
|
-
"integrity": "sha384-
|
|
34
|
-
"fingerprinted": "js/passkey-register.
|
|
37
|
+
"integrity": "sha384-BjuUhbPZ18pHFMyOwT+309BEXu+VAc54RSj8lvN93jfFGDydEgmapiAOKTQM1yma",
|
|
38
|
+
"fingerprinted": "js/passkey-register.02b0e196fb9608d8.js"
|
|
39
|
+
},
|
|
40
|
+
"js/pay.js": {
|
|
41
|
+
"integrity": "sha384-b9H7SuK6CdLZtcZbTouW6uZ2G99L5/joX5z2YyiL7Slg2ub69ZNw2rPZ/Pxw4qBG",
|
|
42
|
+
"fingerprinted": "js/pay.e174b69ddffbbfe4.js"
|
|
43
|
+
},
|
|
44
|
+
"js/paypal-checkout.js": {
|
|
45
|
+
"integrity": "sha384-LI6y/1z0Y9F8Kx8RhW4EwY2WqJPXLwJozCXqnhDT+dTckLHyvhly0SsRpH0bsdui",
|
|
46
|
+
"fingerprinted": "js/paypal-checkout.b05ab5572cc3728f.js"
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
}
|
|
@@ -46,6 +46,16 @@
|
|
|
46
46
|
|
|
47
47
|
var b = require("./vendor/blamejs");
|
|
48
48
|
|
|
49
|
+
// The vendored strict default CSP, as a string constant, so a route can
|
|
50
|
+
// derive a SCOPED copy that admits a specific payment-processor / CAPTCHA-
|
|
51
|
+
// provider host on script-src/connect-src/frame-src WITHOUT relaxing the
|
|
52
|
+
// app-level default everywhere else (see scopedCsp). Required from the
|
|
53
|
+
// vendored module path because the constant is exported on the module, not
|
|
54
|
+
// hung off the `b.middleware.securityHeaders` factory function. This is a
|
|
55
|
+
// top-of-domain composition read of a read-only constant, not a vendored-
|
|
56
|
+
// tree edit.
|
|
57
|
+
var _vendoredSecurityHeaders = require("./vendor/blamejs/lib/middleware/security-headers");
|
|
58
|
+
|
|
49
59
|
var C = b.constants;
|
|
50
60
|
|
|
51
61
|
// Payment webhooks are server-to-server POSTs from Stripe / PayPal:
|
|
@@ -73,6 +83,8 @@ var HEALTH_PATH = "/_/health";
|
|
|
73
83
|
// /account/passkey/ -> register-begin / register-finish / add-* begin+finish
|
|
74
84
|
// /products/ -> the review + question submit sub-paths (gated to POST below)
|
|
75
85
|
// /survey/ -> /survey/:token (GET form + POST submit)
|
|
86
|
+
// /orders/ -> /orders/:id/cancel|rate|reorder (gated to POST below;
|
|
87
|
+
// the /orders/:id confirmation GET a shopper hits freely is NOT throttled)
|
|
76
88
|
var TIGHT_PREFIXES = [
|
|
77
89
|
"/account/login",
|
|
78
90
|
"/account/register",
|
|
@@ -83,6 +95,7 @@ var TIGHT_PREFIXES = [
|
|
|
83
95
|
"/newsletter",
|
|
84
96
|
"/products/",
|
|
85
97
|
"/survey/",
|
|
98
|
+
"/orders/",
|
|
86
99
|
];
|
|
87
100
|
|
|
88
101
|
// Edge-served state-changing POST endpoints. These forms are rendered at
|
|
@@ -176,6 +189,150 @@ function securityHeadersOpts() {
|
|
|
176
189
|
return { documentPolicy: false };
|
|
177
190
|
}
|
|
178
191
|
|
|
192
|
+
// ---- route-scoped CSP (payment processors + CAPTCHA providers) ----------
|
|
193
|
+
//
|
|
194
|
+
// The app-level CSP is the vendored strict default: `script-src 'self'`,
|
|
195
|
+
// `require-trusted-types-for 'script'`, `object-src 'none'`,
|
|
196
|
+
// `default-src 'self'`, `frame-ancestors 'none'` — no inline scripts, no
|
|
197
|
+
// third-party hosts. A few WRITE-side pages legitimately load a third-
|
|
198
|
+
// party SDK from a fixed host: the Stripe / PayPal payment pages, and the
|
|
199
|
+
// auth + checkout pages when an operator has enabled a CAPTCHA provider.
|
|
200
|
+
// Those SDK hosts can't be `'self'`, so the page needs a route-SCOPED CSP
|
|
201
|
+
// that admits ONLY the specific provider origin on the directives that
|
|
202
|
+
// provider needs (script-src / connect-src / frame-src). Every other
|
|
203
|
+
// protection — Trusted Types, object-src, default-src, frame-ancestors —
|
|
204
|
+
// stays exactly as the strict default. The inline glue that drives those
|
|
205
|
+
// SDKs lives in external same-origin islands (themes/default/assets/js/*),
|
|
206
|
+
// so `script-src` never needs `'unsafe-inline'` or a nonce.
|
|
207
|
+
//
|
|
208
|
+
// `scopedCsp(keys)` returns a CSP string for `res.setHeader(
|
|
209
|
+
// "content-security-policy", ...)` on the route's response. Node's
|
|
210
|
+
// setHeader OVERWRITES, so the route's header replaces the app-level
|
|
211
|
+
// default for that one response only; no other route is affected.
|
|
212
|
+
|
|
213
|
+
// The fixed, public host set per provider. Single source of truth —
|
|
214
|
+
// every page that admits a provider names it here, never inline. Only the
|
|
215
|
+
// directives the provider actually loads from are listed (Stripe needs all
|
|
216
|
+
// three; PayPal needs script + frame + connect; a CAPTCHA widget needs all
|
|
217
|
+
// three). Hosts are scheme-qualified https origins, never wildcards beyond
|
|
218
|
+
// what the provider's own CDN requires.
|
|
219
|
+
var CSP_HOSTS = {
|
|
220
|
+
stripe: {
|
|
221
|
+
script: ["https://js.stripe.com"],
|
|
222
|
+
connect: ["https://api.stripe.com"],
|
|
223
|
+
frame: ["https://js.stripe.com", "https://hooks.stripe.com"],
|
|
224
|
+
},
|
|
225
|
+
paypal: {
|
|
226
|
+
script: ["https://www.paypal.com", "https://www.paypalobjects.com"],
|
|
227
|
+
frame: ["https://www.paypal.com"],
|
|
228
|
+
connect: ["https://www.paypal.com"],
|
|
229
|
+
},
|
|
230
|
+
// CAPTCHA providers (only the active provider's host is added per request).
|
|
231
|
+
turnstile: {
|
|
232
|
+
script: ["https://challenges.cloudflare.com"],
|
|
233
|
+
frame: ["https://challenges.cloudflare.com"],
|
|
234
|
+
connect: ["https://challenges.cloudflare.com"],
|
|
235
|
+
},
|
|
236
|
+
hcaptcha: {
|
|
237
|
+
script: ["https://js.hcaptcha.com", "https://hcaptcha.com"],
|
|
238
|
+
frame: ["https://hcaptcha.com", "https://*.hcaptcha.com"],
|
|
239
|
+
connect: ["https://hcaptcha.com", "https://*.hcaptcha.com"],
|
|
240
|
+
},
|
|
241
|
+
recaptcha: {
|
|
242
|
+
script: ["https://www.google.com", "https://www.gstatic.com"],
|
|
243
|
+
frame: ["https://www.google.com"],
|
|
244
|
+
connect: ["https://www.google.com"],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Directive name → the CSP_HOSTS sub-key that feeds it.
|
|
249
|
+
var _SCOPED_DIRECTIVES = {
|
|
250
|
+
"script-src": "script",
|
|
251
|
+
"connect-src": "connect",
|
|
252
|
+
"frame-src": "frame",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Directives the scoped builder MUST NOT touch — relaxing any of these
|
|
256
|
+
// would weaken the page's defense, which is the whole point this group
|
|
257
|
+
// guards against. Listed for the in-file invariant; the builder only ever
|
|
258
|
+
// appends to the three _SCOPED_DIRECTIVES entries.
|
|
259
|
+
// default-src 'self', object-src 'none',
|
|
260
|
+
// require-trusted-types-for 'script', frame-ancestors 'none', base-uri 'self'
|
|
261
|
+
|
|
262
|
+
// Parse the vendored default CSP into an ordered directive map ONCE at
|
|
263
|
+
// module load. The default is a `name a b; name c;` string; split on `;`,
|
|
264
|
+
// the first token of each clause is the directive name, the rest its
|
|
265
|
+
// source list.
|
|
266
|
+
function _parseCsp(cspString) {
|
|
267
|
+
var order = [];
|
|
268
|
+
var map = {};
|
|
269
|
+
String(cspString).split(";").forEach(function (clause) {
|
|
270
|
+
var trimmed = clause.trim();
|
|
271
|
+
if (!trimmed) return;
|
|
272
|
+
var parts = trimmed.split(/\s+/);
|
|
273
|
+
var name = parts[0];
|
|
274
|
+
order.push(name);
|
|
275
|
+
map[name] = parts.slice(1);
|
|
276
|
+
});
|
|
277
|
+
return { order: order, map: map };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var _DEFAULT_CSP_PARSED = _parseCsp(_vendoredSecurityHeaders.DEFAULT_CSP);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build a route-scoped CSP string from the vendored strict default plus the
|
|
284
|
+
* named providers' hosts. `keys` is an array of CSP_HOSTS keys
|
|
285
|
+
* ("stripe" / "paypal" / "turnstile" / "hcaptcha" / "recaptcha"); each
|
|
286
|
+
* adds its hosts to script-src / connect-src / frame-src (creating the
|
|
287
|
+
* directive from `'self'` if the default omits it, e.g. frame-src). Every
|
|
288
|
+
* other directive — including require-trusted-types-for, object-src,
|
|
289
|
+
* default-src, frame-ancestors — is carried through verbatim. Unknown keys
|
|
290
|
+
* are ignored (a defensive request-shape reader: a caller passing a
|
|
291
|
+
* mistyped key gets the strict default for that key, never a throw on the
|
|
292
|
+
* hot path of rendering a pay page). Returns the strict default unchanged
|
|
293
|
+
* when `keys` is empty / not an array.
|
|
294
|
+
*
|
|
295
|
+
* Config-time-ish but called per-response on a render path, so it returns
|
|
296
|
+
* defaults rather than throwing — a bad key degrades to the strict default,
|
|
297
|
+
* which fails safe (the SDK is refused, not admitted too broadly).
|
|
298
|
+
*/
|
|
299
|
+
function scopedCsp(keys) {
|
|
300
|
+
// Clone the parsed default so the per-call mutation never leaks into the
|
|
301
|
+
// module-level snapshot.
|
|
302
|
+
var order = _DEFAULT_CSP_PARSED.order.slice();
|
|
303
|
+
var map = {};
|
|
304
|
+
_DEFAULT_CSP_PARSED.order.forEach(function (name) {
|
|
305
|
+
map[name] = _DEFAULT_CSP_PARSED.map[name].slice();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (Array.isArray(keys)) {
|
|
309
|
+
keys.forEach(function (key) {
|
|
310
|
+
var hostSet = CSP_HOSTS[key];
|
|
311
|
+
if (!hostSet) return;
|
|
312
|
+
Object.keys(_SCOPED_DIRECTIVES).forEach(function (directive) {
|
|
313
|
+
var subKey = _SCOPED_DIRECTIVES[directive];
|
|
314
|
+
var hosts = hostSet[subKey];
|
|
315
|
+
if (!hosts || !hosts.length) return;
|
|
316
|
+
if (!map[directive]) {
|
|
317
|
+
// The default omits frame-src (it relies on the default-src /
|
|
318
|
+
// fenced-frame-src pair); create it from 'self' so admitting a
|
|
319
|
+
// provider frame doesn't accidentally open it to everything.
|
|
320
|
+
map[directive] = ["'self'"];
|
|
321
|
+
order.push(directive);
|
|
322
|
+
}
|
|
323
|
+
hosts.forEach(function (h) {
|
|
324
|
+
if (map[directive].indexOf(h) === -1) map[directive].push(h);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return order.map(function (name) {
|
|
331
|
+
var srcs = map[name];
|
|
332
|
+
return srcs.length ? (name + " " + srcs.join(" ")) : name;
|
|
333
|
+
}).join("; ") + ";";
|
|
334
|
+
}
|
|
335
|
+
|
|
179
336
|
/**
|
|
180
337
|
* Build the GLOBAL rate-limit options for createApp's
|
|
181
338
|
* `middleware.rateLimit`. Token-bucket so a bursty-but-bounded browsing
|
|
@@ -298,15 +455,19 @@ function mountRouteGuards(r) {
|
|
|
298
455
|
// Never throttle the webhook or health paths.
|
|
299
456
|
if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
|
|
300
457
|
if (!_hasPrefix(pathname, TIGHT_PREFIXES)) return next();
|
|
301
|
-
// `/products
|
|
302
|
-
// render) that a shopper hits
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
458
|
+
// `/products/`, `/gift-cards`, and `/orders/` carry GET reads (PDP,
|
|
459
|
+
// balance page render, order confirmation page) that a shopper hits
|
|
460
|
+
// freely — only gate the state-changing / lookup POSTs there. For
|
|
461
|
+
// `/orders/` that's the cancel/rate/reorder mutations; the
|
|
462
|
+
// `GET /orders/:id` confirmation page (a customer reloads it
|
|
463
|
+
// repeatedly) is never throttled. Login / register / passkey /
|
|
464
|
+
// checkout / newsletter / survey are gated on every method (their
|
|
465
|
+
// GETs are the form render, which a spray would hammer just the same
|
|
466
|
+
// to harvest a fresh token, so the budget covers both).
|
|
307
467
|
var method = (req.method || "GET").toUpperCase();
|
|
308
468
|
var gateAllMethods = pathname.indexOf("/products/") !== 0 &&
|
|
309
|
-
pathname.indexOf("/gift-cards") !== 0
|
|
469
|
+
pathname.indexOf("/gift-cards") !== 0 &&
|
|
470
|
+
pathname.indexOf("/orders/") !== 0;
|
|
310
471
|
if (!gateAllMethods && method !== "POST") return next();
|
|
311
472
|
return tightLimiter(req, res, next);
|
|
312
473
|
});
|
|
@@ -319,6 +480,8 @@ module.exports = {
|
|
|
319
480
|
securityHeadersOpts: securityHeadersOpts,
|
|
320
481
|
globalRateLimitOpts: globalRateLimitOpts,
|
|
321
482
|
mountRouteGuards: mountRouteGuards,
|
|
483
|
+
scopedCsp: scopedCsp,
|
|
484
|
+
CSP_HOSTS: CSP_HOSTS,
|
|
322
485
|
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
323
486
|
HEALTH_PATH: HEALTH_PATH,
|
|
324
487
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|