@blamejs/blamejs-shop 0.4.24 → 0.4.26
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 +1 -1
- package/SECURITY.md +33 -1
- package/lib/admin.js +101 -35
- package/lib/asset-manifest.json +3 -3
- package/lib/compliance-export.js +79 -14
- package/lib/order.js +96 -12
- package/lib/payment.js +91 -18
- package/lib/save-for-later.js +46 -0
- package/lib/security-middleware.js +26 -2
- package/lib/store-credit.js +41 -0
- package/lib/storefront.js +17 -3
- package/lib/suggestion-box.js +124 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.26 (2026-06-06) — **Guest orders attach to an account on verified sign-in; privacy exports and erasures now cover suggestions, saved-for-later, and store credit; HSTS ships on container responses behind the CDN; and internal cron POSTs are secret-checked at the edge.** A guest-order-claim, privacy-completeness, and edge-hardening release. When a shopper who checked out as a guest later signs in or registers with a verified email that matches the order, those orders now attach to their account and appear in their order history. A subject-access export now includes the customer's suggestion-box submissions, their save-for-later list, and their store-credit ledger, and an erasure anonymizes the suggestions, deletes the saved list, and retains the store-credit ledger under its accounting basis — a domain whose reader isn't wired still shows in the export manifest rather than being silently dropped. Strict-Transport-Security now ships on responses served directly by the container behind the Cloudflare proxy, not only on edge-rendered pages. The worker now verifies the shared secret on internal cron and event POSTs before forwarding them to the container. Upgrade applies one D1 migration. **Added:** *Guest orders reconcile to an account on verified sign-in* — An order placed as a guest carries the buyer's email as a one-way hash. When that buyer later signs in or registers — including through Sign in with Google — and their verified email hashes to the same value, the matching guest orders attach to their account and show up under their order history. Attachment is driven only by verified-email ownership, never by unauthenticated email knowledge, and is idempotent: re-signing-in attaches nothing new, a non-matching email attaches nothing, and the placing-browser cookie and emailed-access-token routes to a guest order keep working unchanged. **Changed:** *Privacy export and erasure cover suggestions, saved-for-later, and store credit* — A full subject-access export now includes three more customer-keyed domains: the customer's suggestion-box submissions (product ideas and complaints, matched on the account id or the hashed email the submission carried), their save-for-later list, and their store-credit balance and ledger history. Erasure anonymizes each suggestion in place — severing both identity keys so the row can no longer be traced to the person while leaving the de-identified roadmap signal — deletes the save-for-later list outright, and retains the store-credit ledger under the same accounting / legal-obligation basis the loyalty ledger and gift cards already use. Each domain reports its effect in the request's completeness manifest, so a domain whose reader isn't wired is visible as absent rather than silently omitted. **Security:** *HSTS ships on container responses behind the proxy* — Strict-Transport-Security is emitted on responses served directly by the application container, not only on pages rendered at the edge. Behind the Cloudflare proxy the container connection is plain HTTP and the real scheme arrives in the forwarded-proto header; the security-headers middleware now trusts that header, so a direct-to-container or edge-render-off response carries the same two-year, includeSubDomains, preload HSTS value the edge sends. Plain-HTTP local and direct connections still omit the header, which user agents ignore over HTTP anyway. · *Internal cron and event POSTs are secret-checked at the edge* — The worker now verifies the shared secret on its internal cron and event POSTs (cart-recovery, stock-alert and wishlist sweeps, the stale-order reap, portal-session expiry, and campaign sends) before forwarding them to the container, refusing a forged public request at the edge instead of relying solely on the container's own check. The check is skipped when the secret isn't configured, so a deployment that hasn't set it still forwards rather than refusing every scheduled task.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
69
69
|
| **`lib/delivery-estimate.js`** | "Get it by <date>" promises. Operators define carrier transit times, warehouse cutoffs, holidays, and postal-prefix zones at `/admin/delivery-estimates`; the product and cart pages then show a signed-in customer a delivery date computed against their saved shipping address and the configured origin (`SHOP_ESTIMATE_ORIGIN` or the `shop.estimate_origin` config row). Anonymous, edge-cached pages deliberately render no date — estimates are destination-specific and never bake into the shared cache. Unconfigured stores render nothing, never an error. |
|
|
70
70
|
| **`lib/promo-banners.js`** | Placement-targeted marketing banners (top strip, homepage hero, PDP-side, cart-side, empty-search, footer) with schedule windows, audience targeting, themes, priorities, and click/impression counts. Authored at `/admin/promo-banners`; rendered on both substrates with edge-cache-safe resolution. |
|
|
71
71
|
| **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
|
|
72
|
-
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
|
|
72
|
+
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. Guest orders carry the buyer's email as a one-way hash and attach to a customer account when a verified sign-in (passkey / Google / Apple) proves ownership of the same address — never from email knowledge alone — after which they appear in the account's order history. |
|
|
73
73
|
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
|
|
74
74
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation, wishlist price-drop, abandoned-cart, review request, back-in-stock, **wishlist digest** (the periodic saved-items rollup, rendered per-line from the structured digest so every title / price is independently escaped), and **email magic-link sign-in**. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
|
75
75
|
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, quote review / accept, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
|
package/SECURITY.md
CHANGED
|
@@ -142,7 +142,12 @@ node -e "
|
|
|
142
142
|
ticks): those paths skip the bot guard's browser-fingerprint
|
|
143
143
|
heuristics — the Worker's machine-to-machine calls carry no browser
|
|
144
144
|
headers — so the constant-time secret check is the deciding gate,
|
|
145
|
-
and an unauthenticated caller gets a 401.
|
|
145
|
+
and an unauthenticated caller gets a 401. The Worker also verifies
|
|
146
|
+
the same secret on these cron/event POSTs before forwarding them, so
|
|
147
|
+
a forged public request is refused at the edge before it reaches the
|
|
148
|
+
container (the edge check is skipped when the secret is unset, so a
|
|
149
|
+
deployment that hasn't wired it still forwards rather than dropping
|
|
150
|
+
every scheduled task). Generate ≥ 32 bytes of OS
|
|
146
151
|
randomness and set the value identically on the Worker (`wrangler
|
|
147
152
|
secret put D1_BRIDGE_SECRET`) and the container env. Rotate
|
|
148
153
|
quarterly or after any Worker-credential compromise.
|
|
@@ -293,6 +298,33 @@ node -e "
|
|
|
293
298
|
(success / failure / denied) and paginated. Opening the log is itself
|
|
294
299
|
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
295
300
|
its own forensic mark.
|
|
301
|
+
- **Privacy exports hold the whole record; erasure states a basis per
|
|
302
|
+
domain.** A subject-access export walks every table that keys a row
|
|
303
|
+
by the customer — identity, orders, subscriptions, addresses, saved
|
|
304
|
+
payment-method metadata, support tickets, loyalty, reviews, the
|
|
305
|
+
consent ledger, wishlist, surveys, recently-viewed, suggestion-box
|
|
306
|
+
submissions, the save-for-later list, and the store-credit ledger —
|
|
307
|
+
so the bundle is the full record, not just the order/identity core.
|
|
308
|
+
The bundle carries a completeness manifest: every in-scope domain is
|
|
309
|
+
marked exported, empty, or absent, so a domain whose reader isn't
|
|
310
|
+
wired is visible rather than silently dropped. Erasure deletes the
|
|
311
|
+
pure-personalization domains (wishlist, recently-viewed, save-for-
|
|
312
|
+
later), anonymizes the suggestion-box rows in place (both identity
|
|
313
|
+
keys cleared, the de-identified text left as roadmap signal), revokes
|
|
314
|
+
every sign-in path and anonymizes the customer profile, and RETAINS
|
|
315
|
+
the records a controller keeps under a legal-obligation / accounting
|
|
316
|
+
basis (orders, loyalty ledger, store-credit ledger, consent
|
|
317
|
+
evidence, published reviews) — each with a stated basis in the
|
|
318
|
+
per-domain result. Preview a deletion with the dry-run flag to see
|
|
319
|
+
the blast radius before the irreversible call.
|
|
320
|
+
- **HSTS on every TLS response, edge and container.** Both substrates
|
|
321
|
+
send `Strict-Transport-Security: max-age=63072000; includeSubDomains;
|
|
322
|
+
preload` (two years, above the preload-list minimum). The container
|
|
323
|
+
emits it behind the Cloudflare proxy by trusting the Worker-set
|
|
324
|
+
`X-Forwarded-Proto`, so a direct-to-container or edge-render-off
|
|
325
|
+
response carries the same header the edge sends; a plain-HTTP request
|
|
326
|
+
(local dev, direct non-TLS) omits it, which user agents ignore over
|
|
327
|
+
HTTP anyway.
|
|
296
328
|
- **Email deliverability — SPF / DKIM / DMARC + one-click unsubscribe.**
|
|
297
329
|
Transactional and broadcast mail is composed on `b.mail`, which signs
|
|
298
330
|
each message with DKIM, but the receiving server still rejects or
|
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
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
124
|
-
//
|
|
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
|
|
158
|
-
//
|
|
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
|
|
192
|
+
if (typeof auditAction !== "string" || !auditAction.length) return OWNER_ONLY_PERMISSION;
|
|
161
193
|
var seg = auditAction.split(".")[0];
|
|
162
|
-
return _ACTION_PERMISSION[seg] ||
|
|
194
|
+
return _ACTION_PERMISSION[seg] || OWNER_ONLY_PERMISSION;
|
|
163
195
|
}
|
|
164
196
|
|
|
165
|
-
// True when `role` grants `permission`.
|
|
166
|
-
//
|
|
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
|
-
|
|
169
|
-
|
|
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 =
|
|
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
|
|
14190
|
-
// it is escaped anyway to keep the render
|
|
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(
|
|
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,
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.26",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fingerprinted": "js/passkey-register.02b0e196fb9608d8.js"
|
|
39
39
|
},
|
|
40
40
|
"js/pay-trusted-types.js": {
|
|
41
|
-
"integrity": "sha384-
|
|
42
|
-
"fingerprinted": "js/pay-trusted-types.
|
|
41
|
+
"integrity": "sha384-h+/EjwX7ee88MouudamS26SgRg1bUXasRs9qD0Wcg6bGkK51CluebA3iATzedwdz",
|
|
42
|
+
"fingerprinted": "js/pay-trusted-types.08dc910a5c35d38a.js"
|
|
43
43
|
},
|
|
44
44
|
"js/pay.js": {
|
|
45
45
|
"integrity": "sha384-W11JVQhv1RZq4WhsAOglu56gTZTDz1ByLd+b1HFQBCi8xGoEhJ0PZ2yI3FqbYzt6",
|
package/lib/compliance-export.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* The primitive owns the request lifecycle + composes per-domain
|
|
12
12
|
* readers (customers, order, order-notes, subscriptions,
|
|
13
13
|
* addresses, payment-methods, support-tickets, loyalty, reviews,
|
|
14
|
-
* consent ledger, wishlist, surveys, recently-viewed
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* consent ledger, wishlist, surveys, recently-viewed, suggestion
|
|
15
|
+
* box, save-for-later, store credit) to assemble the bundle —
|
|
16
|
+
* every table that keys a row by the customer, so the export holds
|
|
17
|
+
* the whole record, not just the order/identity core.
|
|
17
18
|
* Delivery (email / signed URL / secure
|
|
18
19
|
* download portal) is the operator worker's concern — this
|
|
19
20
|
* primitive returns the bundle as structured JSON and stamps
|
|
@@ -83,7 +84,8 @@
|
|
|
83
84
|
* orders, subscriptions, addresses, payment
|
|
84
85
|
* methods, support tickets, loyalty, reviews,
|
|
85
86
|
* consent ledger, wishlist, surveys,
|
|
86
|
-
* recently-viewed
|
|
87
|
+
* recently-viewed, suggestion box,
|
|
88
|
+
* save-for-later, store credit).
|
|
87
89
|
* - `orders_only` — only `order` + `orderNotes` contribute;
|
|
88
90
|
* identity / loyalty / subscriptions /
|
|
89
91
|
* addresses / payment methods / support
|
|
@@ -101,7 +103,7 @@
|
|
|
101
103
|
* @related customers, order, orderNotes, subscriptions, addresses,
|
|
102
104
|
* paymentMethods, supportTickets, loyalty, reviews,
|
|
103
105
|
* consentLedger, wishlist, customerSurveys, recentlyViewed,
|
|
104
|
-
* orderExport
|
|
106
|
+
* suggestionBox, saveForLater, storeCredit, orderExport
|
|
105
107
|
*/
|
|
106
108
|
|
|
107
109
|
var b = require("./vendor/blamejs");
|
|
@@ -115,6 +117,48 @@ var STATUSES = Object.freeze([
|
|
|
115
117
|
"received", "processing", "fulfilled", "delivered", "dismissed",
|
|
116
118
|
]);
|
|
117
119
|
|
|
120
|
+
// Statutory response window per jurisdiction — the clock a supervisory
|
|
121
|
+
// authority measures the controller against once a subject files the
|
|
122
|
+
// request. The deadline is `requested_at + days`. GDPR Art. 12(3): one
|
|
123
|
+
// month from receipt (encoded as 30 days — the controller-defensible
|
|
124
|
+
// reading; extendable by two further months for complex requests, which an
|
|
125
|
+
// operator records out of band). CCPA Cal. Civ. Code §1798.130(a)(2): 45
|
|
126
|
+
// days (one 45-day extension permitted). LGPD Art. 19 §II / §3: 15 days for
|
|
127
|
+
// the full declaration. `other` carries no statutory clock — the operator's
|
|
128
|
+
// own SLA governs, so no deadline is surfaced rather than inventing one.
|
|
129
|
+
//
|
|
130
|
+
// This is the DSR-response analogue of b.breach.deadline (which encodes the
|
|
131
|
+
// US-state breach-NOTIFICATION statutes — a different clock with different
|
|
132
|
+
// citations); a subject-access response window has no entry in that
|
|
133
|
+
// registry, so the per-jurisdiction window lives here keyed to the same
|
|
134
|
+
// jurisdiction vocabulary the request rows already carry.
|
|
135
|
+
var DSR_RESPONSE_WINDOW = Object.freeze({
|
|
136
|
+
gdpr: Object.freeze({ days: 30, statute: "GDPR Art. 12(3) (one month from receipt)" }),
|
|
137
|
+
ccpa: Object.freeze({ days: 45, statute: "Cal. Civ. Code §1798.130(a)(2)" }),
|
|
138
|
+
lgpd: Object.freeze({ days: 15, statute: "LGPD Art. 19 §II" }),
|
|
139
|
+
other: null, // operator SLA governs — no statutory clock to surface
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
var MS_PER_DAY = b.constants.TIME.days(1);
|
|
143
|
+
|
|
144
|
+
// Compute the statutory response deadline for a DSR request. Returns null
|
|
145
|
+
// for a jurisdiction with no statutory clock (`other`) or a non-finite
|
|
146
|
+
// requested-at. The shape mirrors b.breach.deadline.forStates entries —
|
|
147
|
+
// `{ jurisdiction, days, due_by, statute }` — so a future operator clock
|
|
148
|
+
// (b.breach.deadline.createClock-style escalation) can adapt it without a
|
|
149
|
+
// reshape.
|
|
150
|
+
function _statutoryDeadline(jurisdiction, requestedAtMs) {
|
|
151
|
+
var win = DSR_RESPONSE_WINDOW[jurisdiction];
|
|
152
|
+
if (!win) return null;
|
|
153
|
+
if (typeof requestedAtMs !== "number" || !isFinite(requestedAtMs)) return null;
|
|
154
|
+
return {
|
|
155
|
+
jurisdiction: jurisdiction,
|
|
156
|
+
days: win.days,
|
|
157
|
+
due_by: requestedAtMs + (win.days * MS_PER_DAY),
|
|
158
|
+
statute: win.statute,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
118
162
|
var MAX_REASON_LEN = 4000;
|
|
119
163
|
var MAX_DISMISS_REASON_LEN = 4000;
|
|
120
164
|
var MAX_DELIVERY_METHOD_LEN = 64;
|
|
@@ -146,6 +190,11 @@ var SCOPE_SECTIONS = Object.freeze({
|
|
|
146
190
|
"customers", "addresses", "order", "orderNotes",
|
|
147
191
|
"subscriptions", "paymentMethods", "supportTickets", "loyalty",
|
|
148
192
|
"reviews", "consentLedger", "wishlist", "surveys", "recentlyViewed",
|
|
193
|
+
// Customer-authored feedback (ideas + complaints, keyed by id or a
|
|
194
|
+
// hashed email), the save-for-later holdover list, and the store-
|
|
195
|
+
// credit wallet ledger — each keys a row by the customer, so a
|
|
196
|
+
// subject-access export holds them too.
|
|
197
|
+
"suggestionBox", "saveForLater", "storeCredit",
|
|
149
198
|
]),
|
|
150
199
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
151
200
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -264,6 +313,7 @@ function _isEmptySection(section) {
|
|
|
264
313
|
|
|
265
314
|
function _hydrate(r) {
|
|
266
315
|
if (!r) return null;
|
|
316
|
+
var requestedAt = Number(r.requested_at);
|
|
267
317
|
return {
|
|
268
318
|
id: r.id,
|
|
269
319
|
customer_id: r.customer_id,
|
|
@@ -272,13 +322,19 @@ function _hydrate(r) {
|
|
|
272
322
|
scope: r.scope == null ? null : r.scope,
|
|
273
323
|
status: r.status,
|
|
274
324
|
requested_by: r.requested_by,
|
|
275
|
-
requested_at:
|
|
325
|
+
requested_at: requestedAt,
|
|
276
326
|
fulfilled_at: r.fulfilled_at == null ? null : Number(r.fulfilled_at),
|
|
277
327
|
delivered_at: r.delivered_at == null ? null : Number(r.delivered_at),
|
|
278
328
|
dismiss_reason: r.dismiss_reason == null ? null : r.dismiss_reason,
|
|
279
329
|
delivery_method: r.delivery_method == null ? null : r.delivery_method,
|
|
280
330
|
delivery_address: r.delivery_address == null ? null : r.delivery_address,
|
|
281
331
|
reason: r.reason == null ? null : r.reason,
|
|
332
|
+
// Derived: the statutory response deadline the supervisory authority
|
|
333
|
+
// measures against (computed from jurisdiction + requested_at, never
|
|
334
|
+
// persisted — so it always reflects the current registry). Null for a
|
|
335
|
+
// jurisdiction with no statutory clock. The admin DSR console surfaces
|
|
336
|
+
// `due_by` so an operator sees the wall before it elapses.
|
|
337
|
+
statutory_deadline: _statutoryDeadline(r.jurisdiction, requestedAt),
|
|
282
338
|
};
|
|
283
339
|
}
|
|
284
340
|
|
|
@@ -312,6 +368,9 @@ function create(opts) {
|
|
|
312
368
|
wishlist: opts.wishlist || null,
|
|
313
369
|
surveys: opts.surveys || null,
|
|
314
370
|
recentlyViewed: opts.recentlyViewed || null,
|
|
371
|
+
suggestionBox: opts.suggestionBox || null,
|
|
372
|
+
saveForLater: opts.saveForLater || null,
|
|
373
|
+
storeCredit: opts.storeCredit || null,
|
|
315
374
|
};
|
|
316
375
|
|
|
317
376
|
// ---- requestExport -------------------------------------------------
|
|
@@ -560,9 +619,10 @@ function create(opts) {
|
|
|
560
619
|
}
|
|
561
620
|
|
|
562
621
|
var domainOrder = [
|
|
563
|
-
"recentlyViewed", "wishlist", "
|
|
622
|
+
"recentlyViewed", "wishlist", "saveForLater", "suggestionBox",
|
|
623
|
+
"surveys", "reviews", "consentLedger",
|
|
564
624
|
"supportTickets", "orderNotes", "order", "subscriptions",
|
|
565
|
-
"paymentMethods", "loyalty", "addresses", "customers",
|
|
625
|
+
"paymentMethods", "loyalty", "storeCredit", "addresses", "customers",
|
|
566
626
|
];
|
|
567
627
|
var perDomain = [];
|
|
568
628
|
var domainsAbsent = [];
|
|
@@ -658,10 +718,15 @@ function create(opts) {
|
|
|
658
718
|
}
|
|
659
719
|
|
|
660
720
|
module.exports = {
|
|
661
|
-
create:
|
|
662
|
-
REQUEST_KINDS:
|
|
663
|
-
JURISDICTIONS:
|
|
664
|
-
SCOPES:
|
|
665
|
-
STATUSES:
|
|
666
|
-
SCOPE_SECTIONS:
|
|
721
|
+
create: create,
|
|
722
|
+
REQUEST_KINDS: REQUEST_KINDS,
|
|
723
|
+
JURISDICTIONS: JURISDICTIONS,
|
|
724
|
+
SCOPES: SCOPES,
|
|
725
|
+
STATUSES: STATUSES,
|
|
726
|
+
SCOPE_SECTIONS: SCOPE_SECTIONS,
|
|
727
|
+
// DSR statutory response-window registry + the per-request deadline
|
|
728
|
+
// calculator (the console reads the calculator's `due_by`; tests pin the
|
|
729
|
+
// per-jurisdiction windows).
|
|
730
|
+
DSR_RESPONSE_WINDOW: DSR_RESPONSE_WINDOW,
|
|
731
|
+
statutoryDeadline: _statutoryDeadline,
|
|
667
732
|
};
|
package/lib/order.js
CHANGED
|
@@ -110,6 +110,14 @@ var ORDER_STATES = Object.freeze([
|
|
|
110
110
|
"pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled",
|
|
111
111
|
]);
|
|
112
112
|
|
|
113
|
+
// The proof routes a guest-order reconciliation can be attributed to.
|
|
114
|
+
// "verified-email" = an OIDC sign-in whose provider verified the address;
|
|
115
|
+
// "magic-link" = the buyer clicked a sign-in link emailed to the address.
|
|
116
|
+
// Both prove control of the email — the trust anchor for the attach. An
|
|
117
|
+
// unknown linked_via falls back to "verified-email" so a typo can't write
|
|
118
|
+
// an unfiltered audit string.
|
|
119
|
+
var RECONCILE_LINKED_VIA = Object.freeze(["verified-email", "magic-link"]);
|
|
120
|
+
|
|
113
121
|
// Cursor key for listForCustomer — paginates by (updated_at DESC, id
|
|
114
122
|
// DESC) so a newly transitioned order surfaces at the top of the
|
|
115
123
|
// customer's order history without a stable-id tie-break flake.
|
|
@@ -1051,22 +1059,98 @@ function create(opts) {
|
|
|
1051
1059
|
|
|
1052
1060
|
// Claim guest orders into a customer account by matching the
|
|
1053
1061
|
// recorded buyer-email hash. The CALLER must only pass a hash for an
|
|
1054
|
-
// email the identity provider VERIFIED
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1062
|
+
// email the identity provider VERIFIED (an OIDC email_verified claim)
|
|
1063
|
+
// or the buyer has otherwise proven control of (clicking an emailed
|
|
1064
|
+
// magic-link) — this method does not (and cannot) re-verify; linking
|
|
1065
|
+
// an unverified email would be account takeover. Only touches orders
|
|
1066
|
+
// with no owner yet (customer_id IS NULL), so it never re-assigns
|
|
1067
|
+
// another customer's order.
|
|
1068
|
+
//
|
|
1069
|
+
// Each newly-attached order also gets one append-only row in
|
|
1070
|
+
// guest_order_reconciliations recording WHEN it was attached, to WHICH
|
|
1071
|
+
// customer, by WHICH proof (linked_via), and the email_hash matched —
|
|
1072
|
+
// so a disputed link is traceable and an operator/DSR review can replay
|
|
1073
|
+
// it without inferring it from a customer_id that was overwritten. The
|
|
1074
|
+
// audit row is the verified linking key only (the plaintext address is
|
|
1075
|
+
// never stored anywhere).
|
|
1076
|
+
//
|
|
1077
|
+
// Idempotent at BOTH layers: the per-order claim-guard UPDATE only
|
|
1078
|
+
// flips a still-unowned order (so a re-run links nothing new), and the
|
|
1079
|
+
// audit INSERT is INSERT-OR-IGNORE against a UNIQUE (order_id,
|
|
1080
|
+
// customer_id) index (so even a forced re-write records nothing new).
|
|
1081
|
+
// The audit table is optional — when the migration hasn't run (a
|
|
1082
|
+
// partial-schema test, or a deploy mid-migration) the INSERT throws and
|
|
1083
|
+
// is swallowed so reconciliation never fails on the audit write; the
|
|
1084
|
+
// attach (the load-bearing effect) still lands.
|
|
1085
|
+
//
|
|
1086
|
+
// `linkedVia` names the proof route — defaulted + constrained to a known
|
|
1087
|
+
// token so a typo can't write an unfiltered string; an unknown value
|
|
1088
|
+
// falls back to "verified-email". Returns the count linked.
|
|
1089
|
+
linkGuestOrdersByEmailHash: async function (customerId, emailHash, opts2) {
|
|
1060
1090
|
_uuid(customerId, "customer id");
|
|
1061
1091
|
if (typeof emailHash !== "string" || !emailHash.length) {
|
|
1062
1092
|
throw new TypeError("order.linkGuestOrdersByEmailHash: emailHash must be a non-empty string");
|
|
1063
1093
|
}
|
|
1064
|
-
var
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
)
|
|
1069
|
-
|
|
1094
|
+
var linkedVia = (opts2 && opts2.linked_via) || "verified-email";
|
|
1095
|
+
if (RECONCILE_LINKED_VIA.indexOf(linkedVia) === -1) linkedVia = "verified-email";
|
|
1096
|
+
|
|
1097
|
+
// The candidate set: every still-unowned order placed under this
|
|
1098
|
+
// verified email. Claiming per-id (not one bulk UPDATE) is what lets
|
|
1099
|
+
// the audit trail name the exact orders attached AND keeps the attach
|
|
1100
|
+
// race-safe — a concurrent reconciliation for the same email can't
|
|
1101
|
+
// double-claim because each UPDATE re-checks customer_id IS NULL.
|
|
1102
|
+
var candidates = (await query(
|
|
1103
|
+
"SELECT id FROM orders WHERE customer_id IS NULL AND customer_email_hash = ?1",
|
|
1104
|
+
[emailHash],
|
|
1105
|
+
)).rows;
|
|
1106
|
+
var linked = 0;
|
|
1107
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
1108
|
+
var orderId = candidates[i].id;
|
|
1109
|
+
var ts = _now();
|
|
1110
|
+
// Claim-guard: only flip an order STILL unowned at write time.
|
|
1111
|
+
var upd = await query(
|
|
1112
|
+
"UPDATE orders SET customer_id = ?1, updated_at = ?2 " +
|
|
1113
|
+
"WHERE id = ?3 AND customer_id IS NULL",
|
|
1114
|
+
[customerId, ts, orderId],
|
|
1115
|
+
);
|
|
1116
|
+
if (Number(upd.rowCount || 0) === 0) continue; // lost the race — another writer claimed it.
|
|
1117
|
+
linked += 1;
|
|
1118
|
+
// Append-only audit row. INSERT OR IGNORE against the UNIQUE
|
|
1119
|
+
// (order_id, customer_id) index, so a re-run records nothing new.
|
|
1120
|
+
// Swallowed on a missing-table error so the attach never fails on
|
|
1121
|
+
// the audit write (the attach already landed above).
|
|
1122
|
+
try {
|
|
1123
|
+
await query(
|
|
1124
|
+
"INSERT OR IGNORE INTO guest_order_reconciliations " +
|
|
1125
|
+
"(id, order_id, customer_id, email_hash, linked_via, occurred_at) " +
|
|
1126
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
1127
|
+
[b.uuid.v7(), orderId, customerId, emailHash, linkedVia, ts],
|
|
1128
|
+
);
|
|
1129
|
+
} catch (_e) { /* drop-silent — audit table optional; the attach is the load-bearing effect */ }
|
|
1130
|
+
}
|
|
1131
|
+
return linked;
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
// Every guest order reconciled into a customer account, newest first —
|
|
1135
|
+
// the operator/DSR "how did this order get attached?" view. Returns the
|
|
1136
|
+
// append-only audit rows (order_id, email_hash, linked_via, occurred_at)
|
|
1137
|
+
// for the customer; an account that never claimed a guest order returns
|
|
1138
|
+
// []. Defensive: when the audit table hasn't been migrated yet the read
|
|
1139
|
+
// throws and is collapsed to [] (the feature degrades to "no trail",
|
|
1140
|
+
// never a 500).
|
|
1141
|
+
reconciliationsForCustomer: async function (customerId) {
|
|
1142
|
+
_uuid(customerId, "customer id");
|
|
1143
|
+
try {
|
|
1144
|
+
var rows = (await query(
|
|
1145
|
+
"SELECT id, order_id, customer_id, email_hash, linked_via, occurred_at " +
|
|
1146
|
+
"FROM guest_order_reconciliations WHERE customer_id = ?1 " +
|
|
1147
|
+
"ORDER BY occurred_at DESC, id DESC",
|
|
1148
|
+
[customerId],
|
|
1149
|
+
)).rows;
|
|
1150
|
+
return rows;
|
|
1151
|
+
} catch (_e) {
|
|
1152
|
+
return [];
|
|
1153
|
+
}
|
|
1070
1154
|
},
|
|
1071
1155
|
|
|
1072
1156
|
// Has this customer purchased this product? True iff an order
|
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
|
|
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
|
-
|
|
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:
|
|
272
|
-
url:
|
|
273
|
-
headers:
|
|
274
|
-
body:
|
|
275
|
-
timeoutMs:
|
|
276
|
-
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:
|
|
749
|
-
timeoutMs:
|
|
750
|
-
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
|
-
|
|
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:
|
|
789
|
-
url:
|
|
790
|
-
headers:
|
|
791
|
-
body:
|
|
792
|
-
timeoutMs:
|
|
793
|
-
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 }; }
|
package/lib/save-for-later.js
CHANGED
|
@@ -56,6 +56,12 @@
|
|
|
56
56
|
* current; returns the count of rows actually changed.
|
|
57
57
|
* - `expireOlderThan(days)` — operator-scheduler entry point
|
|
58
58
|
* that prunes saves older than the supplied age.
|
|
59
|
+
* - `exportForCustomer(customer_id)` — DSR export reader: the
|
|
60
|
+
* subject's saved rows.
|
|
61
|
+
* - `eraseForCustomer(customer_id, { dry_run? })` — DSR erasure:
|
|
62
|
+
* deletes the subject's saved rows (pure personalization, no
|
|
63
|
+
* retention basis). Returns the `{ table, deleted }` reader
|
|
64
|
+
* contract.
|
|
59
65
|
*
|
|
60
66
|
* Storage:
|
|
61
67
|
* - `save_for_later` (migration `0041_save_for_later.sql`).
|
|
@@ -641,6 +647,46 @@ function create(opts) {
|
|
|
641
647
|
return { changed: changed };
|
|
642
648
|
},
|
|
643
649
|
|
|
650
|
+
// ---- exportForCustomer / eraseForCustomer (DSR) -----------------
|
|
651
|
+
//
|
|
652
|
+
// Subject-access-request hooks consumed by complianceExport. A
|
|
653
|
+
// save-for-later row keys directly on `customer_id` — there is no
|
|
654
|
+
// hashed-identity branch (every row is account-bound).
|
|
655
|
+
//
|
|
656
|
+
// exportForCustomer(customer_id) — the subject's saved rows. Pure
|
|
657
|
+
// read; capped at MAX_LIMIT so one customer can't unbounded-stream
|
|
658
|
+
// the export.
|
|
659
|
+
exportForCustomer: async function (customerId) {
|
|
660
|
+
var cid = _uuid(customerId, "customer_id");
|
|
661
|
+
var rows = (await query(
|
|
662
|
+
"SELECT * FROM save_for_later WHERE customer_id = ?1 " +
|
|
663
|
+
"ORDER BY saved_at DESC, id DESC LIMIT ?2",
|
|
664
|
+
[cid, MAX_LIMIT],
|
|
665
|
+
)).rows;
|
|
666
|
+
return rows;
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
// eraseForCustomer(customer_id, { dry_run }) — GDPR Art. 17
|
|
670
|
+
// erasure. A save-for-later list is pure personalization with no
|
|
671
|
+
// retention basis, so erasure DELETES every row for the customer
|
|
672
|
+
// (the same effect as `clear`, returned in the complianceExport
|
|
673
|
+
// reader contract shape `{ table, deleted }`). dry_run counts the
|
|
674
|
+
// rows it WOULD remove without mutating. A re-run finds none
|
|
675
|
+
// (idempotent).
|
|
676
|
+
eraseForCustomer: async function (customerId, opts) {
|
|
677
|
+
var cid = _uuid(customerId, "customer_id");
|
|
678
|
+
var dryRun = !!(opts && opts.dry_run);
|
|
679
|
+
if (dryRun) {
|
|
680
|
+
var c = (await query(
|
|
681
|
+
"SELECT COUNT(*) AS n FROM save_for_later WHERE customer_id = ?1",
|
|
682
|
+
[cid],
|
|
683
|
+
)).rows[0];
|
|
684
|
+
return { table: "save_for_later", deleted: c ? Number(c.n) : 0 };
|
|
685
|
+
}
|
|
686
|
+
var r = await query("DELETE FROM save_for_later WHERE customer_id = ?1", [cid]);
|
|
687
|
+
return { table: "save_for_later", deleted: Number(r.rowCount || 0) };
|
|
688
|
+
},
|
|
689
|
+
|
|
644
690
|
// Operator scheduler entry point: remove every save older than
|
|
645
691
|
// the supplied age. Returns the count of removed rows so the
|
|
646
692
|
// cron / scheduled-worker layer can emit a metric.
|
|
@@ -290,7 +290,25 @@ function clientKey(req) {
|
|
|
290
290
|
* so both substrates stay header-consistent.
|
|
291
291
|
*/
|
|
292
292
|
function securityHeadersOpts() {
|
|
293
|
-
|
|
293
|
+
// `trustProxy: true` is what lets the vendored HSTS header actually
|
|
294
|
+
// ship from the container. The vendored securityHeaders middleware
|
|
295
|
+
// emits Strict-Transport-Security ONLY when the request protocol
|
|
296
|
+
// resolves to https (RFC 6797 §7.2: HSTS over plain HTTP is ignored by
|
|
297
|
+
// UAs, so the middleware suppresses it on non-TLS requests). Behind the
|
|
298
|
+
// Cloudflare Worker the container socket is plain http and the real
|
|
299
|
+
// scheme rides in the Worker-set `x-forwarded-proto: https` — without
|
|
300
|
+
// trustProxy the middleware reads `http`, decides the request isn't
|
|
301
|
+
// TLS, and drops HSTS on EVERY container-served response. The edge
|
|
302
|
+
// Worker sets its own HSTS on edge-rendered pages, but a direct-to-
|
|
303
|
+
// container request (edge render off, or an internal hop that returns
|
|
304
|
+
// HTML) then carries no HSTS at all. Opting into the forwarded-proto
|
|
305
|
+
// header — the same stance the csrf gate already takes (mountRouteGuards
|
|
306
|
+
// passes trustProxy:true) and the storefront session-cookie path takes
|
|
307
|
+
// (`_secureForReq`) — restores the header on container responses. HSTS
|
|
308
|
+
// stays OWNED by the vendored middleware (we set no header ourselves),
|
|
309
|
+
// so there is no double-set: on an edge-rendered page only the Worker's
|
|
310
|
+
// header is present; on a container-served page only this one is.
|
|
311
|
+
return { documentPolicy: false, referrerPolicy: "same-origin", trustProxy: true };
|
|
294
312
|
}
|
|
295
313
|
|
|
296
314
|
// ---- route-scoped CSP (payment processors + CAPTCHA providers) ----------
|
|
@@ -608,7 +626,13 @@ function globalRateLimitOpts() {
|
|
|
608
626
|
function botGuardOpts() {
|
|
609
627
|
return {
|
|
610
628
|
skipPaths: INTERNAL_BRIDGE_PATHS.slice()
|
|
611
|
-
.
|
|
629
|
+
// EXACT-match the well-known paths. A bare-string skip is matched as a
|
|
630
|
+
// PREFIX by the bot guard, which would also exempt any sibling under the
|
|
631
|
+
// directory (e.g. /.well-known/apple-...-association/anything), re-opening
|
|
632
|
+
// the guard on routes that aren't the single static association file.
|
|
633
|
+
.concat(PUBLIC_WELL_KNOWN_PATHS.map(function (p) {
|
|
634
|
+
return new RegExp("^" + p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + "$");
|
|
635
|
+
}))
|
|
612
636
|
.concat([/^\/admin(\/|$)/]),
|
|
613
637
|
};
|
|
614
638
|
}
|
package/lib/store-credit.js
CHANGED
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
* - expiringWithin({ customer_id, days })
|
|
53
53
|
* - bulkBalance({ customer_ids })
|
|
54
54
|
* - cleanupExpired({ now })
|
|
55
|
+
* - exportForCustomer(customer_id) — DSR export reader: the
|
|
56
|
+
* subject's balance + full ledger history.
|
|
57
|
+
* - eraseForCustomer(customer_id, { dry_run? }) — DSR erasure:
|
|
58
|
+
* RETAINS (financial ledger, legal-obligation basis). Returns
|
|
59
|
+
* the `{ table, deleted: 0, note }` reader contract.
|
|
55
60
|
*
|
|
56
61
|
* Storage:
|
|
57
62
|
* - store_credit_ledger (migration 0094).
|
|
@@ -491,6 +496,42 @@ function create(opts) {
|
|
|
491
496
|
return out;
|
|
492
497
|
},
|
|
493
498
|
|
|
499
|
+
// ---- exportForCustomer / eraseForCustomer (DSR) -----------------
|
|
500
|
+
//
|
|
501
|
+
// Subject-access-request hooks consumed by complianceExport. The
|
|
502
|
+
// ledger keys on `customer_id`.
|
|
503
|
+
//
|
|
504
|
+
// exportForCustomer(customer_id) — the subject's current balance +
|
|
505
|
+
// their full ledger history (the financial record of every credit /
|
|
506
|
+
// debit / expire against their wallet). Pure read; the history is
|
|
507
|
+
// capped at the same 500-row ceiling `history()` enforces.
|
|
508
|
+
exportForCustomer: async function (customerId) {
|
|
509
|
+
var cid = _uuid(customerId, "customer_id");
|
|
510
|
+
var bal = await _currentBalance(cid);
|
|
511
|
+
var r = await query(
|
|
512
|
+
"SELECT id, customer_id, kind, amount_minor, source, source_ref, order_id, " +
|
|
513
|
+
"balance_after_minor, expires_at, occurred_at FROM store_credit_ledger " +
|
|
514
|
+
"WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 500",
|
|
515
|
+
[cid],
|
|
516
|
+
);
|
|
517
|
+
return { balance_minor: bal, history: r.rows };
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
// eraseForCustomer(customer_id, { dry_run }) — store credit is a
|
|
521
|
+
// financial ledger: an audited record of money owed to and spent by
|
|
522
|
+
// the customer, retained under the controller's legal-obligation /
|
|
523
|
+
// accounting basis (the same posture as the loyalty ledger and gift
|
|
524
|
+
// cards in the DSR reader map). Erasure RETAINS — deleting the rows
|
|
525
|
+
// would destroy the proof of a balance the customer may still be
|
|
526
|
+
// entitled to and the accounting trail a tax / chargeback audit
|
|
527
|
+
// needs. Returns `{ table, deleted: 0, note }`; dry_run reports the
|
|
528
|
+
// same retain decision (0 rows would be removed). The customer-
|
|
529
|
+
// identity scrub rides the anonymized customers row.
|
|
530
|
+
eraseForCustomer: async function (customerId, _opts) {
|
|
531
|
+
_uuid(customerId, "customer_id");
|
|
532
|
+
return { table: "store_credit_ledger", deleted: 0, note: "retained-financial-ledger" };
|
|
533
|
+
},
|
|
534
|
+
|
|
494
535
|
cleanupExpired: async function (input) {
|
|
495
536
|
input = input || {};
|
|
496
537
|
var now = _epochMs(input.now, "now");
|
package/lib/storefront.js
CHANGED
|
@@ -15873,7 +15873,8 @@ function mount(router, deps) {
|
|
|
15873
15873
|
try {
|
|
15874
15874
|
var portalCust = await deps.customers.get(rv.customer_id);
|
|
15875
15875
|
if (portalCust && portalCust.email_hash) {
|
|
15876
|
-
await deps.order.linkGuestOrdersByEmailHash(
|
|
15876
|
+
await deps.order.linkGuestOrdersByEmailHash(
|
|
15877
|
+
rv.customer_id, portalCust.email_hash, { linked_via: "magic-link" });
|
|
15877
15878
|
}
|
|
15878
15879
|
} catch (_eLink) { /* drop-silent — sign-in itself succeeds */ }
|
|
15879
15880
|
}
|
|
@@ -16124,6 +16125,17 @@ function mount(router, deps) {
|
|
|
16124
16125
|
if (anonCart) await deps.cart.setCustomer(anonCart.id, customer.id);
|
|
16125
16126
|
} catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
|
|
16126
16127
|
}
|
|
16128
|
+
// Guest-order reconciliation is deliberately NOT done here. A passkey
|
|
16129
|
+
// assertion proves control of the AUTHENTICATOR, not of the account's
|
|
16130
|
+
// email — and a passkey-registered account's email_hash was never
|
|
16131
|
+
// verified (the register path stores whatever address was typed). The
|
|
16132
|
+
// attach is gated strictly on PROVEN email ownership: an OIDC sign-in
|
|
16133
|
+
// the provider verified, or a magic-link the buyer clicked. Linking on
|
|
16134
|
+
// the account's unverified email_hash here would let an attacker who
|
|
16135
|
+
// registered a passkey under a victim's address inherit the victim's
|
|
16136
|
+
// guest orders — the takeover class the customers model guards against.
|
|
16137
|
+
// A passkey-account holder who wants their guest orders attached signs
|
|
16138
|
+
// in once via the magic-link, which proves the email and reconciles.
|
|
16127
16139
|
_clearChallengeCookie(res);
|
|
16128
16140
|
_setAuthCookie(req, res, {
|
|
16129
16141
|
customer_id: customer.id,
|
|
@@ -16949,7 +16961,8 @@ function mount(router, deps) {
|
|
|
16949
16961
|
if (claims.email_verified === true && claims.email && deps.order &&
|
|
16950
16962
|
typeof deps.order.linkGuestOrdersByEmailHash === "function") {
|
|
16951
16963
|
try {
|
|
16952
|
-
await deps.order.linkGuestOrdersByEmailHash(
|
|
16964
|
+
await deps.order.linkGuestOrdersByEmailHash(
|
|
16965
|
+
rv.customer.id, deps.customers.hashEmail(claims.email), { linked_via: "verified-email" });
|
|
16953
16966
|
} catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
|
|
16954
16967
|
}
|
|
16955
16968
|
// Attribute a referral ONLY on a genuinely new account
|
|
@@ -17063,7 +17076,8 @@ function mount(router, deps) {
|
|
|
17063
17076
|
if (emailVerified && claims.email && deps.order &&
|
|
17064
17077
|
typeof deps.order.linkGuestOrdersByEmailHash === "function") {
|
|
17065
17078
|
try {
|
|
17066
|
-
await deps.order.linkGuestOrdersByEmailHash(
|
|
17079
|
+
await deps.order.linkGuestOrdersByEmailHash(
|
|
17080
|
+
rv.customer.id, deps.customers.hashEmail(claims.email), { linked_via: "verified-email" });
|
|
17067
17081
|
} catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
|
|
17068
17082
|
}
|
|
17069
17083
|
// Attribute a referral ONLY on a genuinely new account (rv.created)
|
package/lib/suggestion-box.js
CHANGED
|
@@ -102,6 +102,13 @@
|
|
|
102
102
|
* - metricsForCategory({ category, from, to })
|
|
103
103
|
* - archiveSuggestion(id)
|
|
104
104
|
* - flagAsSpam({ suggestion_id, flagged })
|
|
105
|
+
* - exportForCustomer({ customer_id?, email_hash? }) — DSR export
|
|
106
|
+
* reader: the subject's own suggestion rows (matched on
|
|
107
|
+
* customer_id OR the suggestion-box-namespace email hash).
|
|
108
|
+
* - eraseForCustomer({ customer_id?, email_hash?, dry_run? }) —
|
|
109
|
+
* DSR erasure: anonymizes the subject's rows in place (both
|
|
110
|
+
* identity keys -> NULL), keeping the de-identified roadmap
|
|
111
|
+
* signal. Returns the `{ table, deleted }` reader contract.
|
|
105
112
|
*
|
|
106
113
|
* Storage: `migrations-d1/0181_suggestion_box.sql` —
|
|
107
114
|
* `suggestions` + `suggestion_votes`.
|
|
@@ -850,6 +857,121 @@ function create(opts) {
|
|
|
850
857
|
return _decode(await _getRaw(sid));
|
|
851
858
|
}
|
|
852
859
|
|
|
860
|
+
// ---- exportForCustomer / eraseForCustomer (DSR) -----------------------
|
|
861
|
+
//
|
|
862
|
+
// Subject-access-request hooks consumed by complianceExport. A
|
|
863
|
+
// suggestion row is keyed to a person by EITHER `customer_id` (an
|
|
864
|
+
// authenticated submission) OR `customer_email_hash` (a storefront
|
|
865
|
+
// visitor who supplied a confirmed email but wasn't signed in). The
|
|
866
|
+
// email is hashed under the `suggestion-box-email` namespace
|
|
867
|
+
// (EMAIL_NAMESPACE), distinct from the customers table's own
|
|
868
|
+
// `customer-email` namespace, and the raw email is never stored —
|
|
869
|
+
// so an email-only row can be matched to a customer ONLY when the
|
|
870
|
+
// caller can supply that same suggestion-box namespace hash.
|
|
871
|
+
//
|
|
872
|
+
// The DSR composition root resolves a customer by `customer_id`
|
|
873
|
+
// (no raw email is held anywhere in the system to re-hash), so it
|
|
874
|
+
// passes `customer_id` and — when the requesting customer's email
|
|
875
|
+
// is known in-session — the derived `email_hash`. Both keys are
|
|
876
|
+
// matched with OR so a customer's authenticated AND email-only
|
|
877
|
+
// submissions are covered. A caller WITH the raw email derives the
|
|
878
|
+
// hash via the exported EMAIL_NAMESPACE + b.crypto.namespaceHash.
|
|
879
|
+
|
|
880
|
+
// Normalize the DSR selector: at least one of customer_id /
|
|
881
|
+
// email_hash must be present. Returns { custId, emailHash } with
|
|
882
|
+
// validated/null values. Defensive request-shape reader at the
|
|
883
|
+
// primitive boundary — bad UUID shape throws (operator catches the
|
|
884
|
+
// typo); a null/absent key is simply not used in the predicate.
|
|
885
|
+
function _dsrSelector(input, method) {
|
|
886
|
+
if (!input || typeof input !== "object") {
|
|
887
|
+
throw new TypeError("suggestionBox." + method + ": input object required");
|
|
888
|
+
}
|
|
889
|
+
var custId = _customerIdOpt(input.customer_id);
|
|
890
|
+
var emailHash = null;
|
|
891
|
+
if (input.email_hash != null) {
|
|
892
|
+
if (typeof input.email_hash !== "string" || !input.email_hash.length || input.email_hash.length > 256) {
|
|
893
|
+
throw new TypeError("suggestionBox." + method + ": email_hash must be a non-empty string <= 256 chars when provided");
|
|
894
|
+
}
|
|
895
|
+
if (CONTROL_BYTE_STRICT_RE.test(input.email_hash) || ZERO_WIDTH_RE.test(input.email_hash)) {
|
|
896
|
+
throw new TypeError("suggestionBox." + method + ": email_hash contains control / zero-width bytes");
|
|
897
|
+
}
|
|
898
|
+
emailHash = input.email_hash;
|
|
899
|
+
}
|
|
900
|
+
if (custId == null && emailHash == null) {
|
|
901
|
+
throw new TypeError("suggestionBox." + method + ": at least one of customer_id / email_hash is required");
|
|
902
|
+
}
|
|
903
|
+
return { custId: custId, emailHash: emailHash };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Build the `customer_id = ? OR customer_email_hash = ?` predicate
|
|
907
|
+
// from whichever selector keys are present, returning { clause,
|
|
908
|
+
// params }. Shared by export + erase so the two never diverge on
|
|
909
|
+
// which rows belong to the subject.
|
|
910
|
+
function _dsrWhere(sel) {
|
|
911
|
+
var ors = [];
|
|
912
|
+
var params = [];
|
|
913
|
+
var idx = 1;
|
|
914
|
+
if (sel.custId != null) { ors.push("customer_id = ?" + idx); params.push(sel.custId); idx += 1; }
|
|
915
|
+
if (sel.emailHash != null) { ors.push("customer_email_hash = ?" + idx); params.push(sel.emailHash); idx += 1; }
|
|
916
|
+
return { clause: "(" + ors.join(" OR ") + ")", params: params };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// exportForCustomer({ customer_id?, email_hash? }) — returns the
|
|
920
|
+
// subject's own suggestion rows (decoded). Read-only; capped at
|
|
921
|
+
// MAX_LIST_LIMIT so a single customer's history can't unbounded-
|
|
922
|
+
// stream the export. Spam-flagged / archived rows ARE included —
|
|
923
|
+
// the subject is entitled to a copy of everything held about them,
|
|
924
|
+
// including operator-only tombstoned rows.
|
|
925
|
+
async function exportForCustomer(input) {
|
|
926
|
+
var sel = _dsrSelector(input, "exportForCustomer");
|
|
927
|
+
var w = _dsrWhere(sel);
|
|
928
|
+
var r = await query(
|
|
929
|
+
"SELECT * FROM suggestions WHERE " + w.clause +
|
|
930
|
+
" ORDER BY created_at DESC, id DESC LIMIT ?" + (w.params.length + 1),
|
|
931
|
+
w.params.concat([MAX_LIST_LIMIT]),
|
|
932
|
+
);
|
|
933
|
+
var out = [];
|
|
934
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
|
|
935
|
+
return out;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// eraseForCustomer({ customer_id?, email_hash?, dry_run? }) —
|
|
939
|
+
// GDPR Art. 17 erasure. A suggestion (especially a `complaint`)
|
|
940
|
+
// carries the customer's own free-text words + a hashed email, so
|
|
941
|
+
// it is personal data with no retention basis once the subject
|
|
942
|
+
// asks for erasure. Rather than DELETE the row (which would orphan
|
|
943
|
+
// any votes / canonical-link pointers and lose the operator's
|
|
944
|
+
// roadmap signal), this ANONYMIZES it in place: severs both
|
|
945
|
+
// identity keys (customer_id + customer_email_hash → NULL) so the
|
|
946
|
+
// row can no longer be traced to the person, while the
|
|
947
|
+
// already-published title/body/votes stay as de-identified
|
|
948
|
+
// roadmap input. dry_run counts the rows it WOULD anonymize
|
|
949
|
+
// without mutating. Returns the complianceExport reader contract
|
|
950
|
+
// shape `{ table, deleted }`.
|
|
951
|
+
async function eraseForCustomer(input) {
|
|
952
|
+
var dryRun = !!(input && input.dry_run);
|
|
953
|
+
var sel = _dsrSelector(input, "eraseForCustomer");
|
|
954
|
+
var w = _dsrWhere(sel);
|
|
955
|
+
// Only rows still carrying an identity key need anonymizing —
|
|
956
|
+
// a re-run finds none (idempotent).
|
|
957
|
+
var countRow = (await query(
|
|
958
|
+
"SELECT COUNT(*) AS n FROM suggestions WHERE " + w.clause +
|
|
959
|
+
" AND (customer_id IS NOT NULL OR customer_email_hash IS NOT NULL)",
|
|
960
|
+
w.params,
|
|
961
|
+
)).rows[0];
|
|
962
|
+
var n = countRow ? Number(countRow.n) : 0;
|
|
963
|
+
if (dryRun) return { table: "suggestions", deleted: n };
|
|
964
|
+
if (n === 0) return { table: "suggestions", deleted: 0 };
|
|
965
|
+
var ts = _now();
|
|
966
|
+
var res = await query(
|
|
967
|
+
"UPDATE suggestions SET customer_id = NULL, customer_email_hash = NULL, updated_at = ?" +
|
|
968
|
+
(w.params.length + 1) + " WHERE " + w.clause +
|
|
969
|
+
" AND (customer_id IS NOT NULL OR customer_email_hash IS NOT NULL)",
|
|
970
|
+
w.params.concat([ts]),
|
|
971
|
+
);
|
|
972
|
+
return { table: "suggestions", deleted: Number((res && res.rowCount) || 0) };
|
|
973
|
+
}
|
|
974
|
+
|
|
853
975
|
// ---- flagAsSpam -------------------------------------------------------
|
|
854
976
|
//
|
|
855
977
|
// Operator-only. Sets spam_flagged = 1 (or back to 0 if the
|
|
@@ -897,6 +1019,8 @@ function create(opts) {
|
|
|
897
1019
|
metricsForCategory: metricsForCategory,
|
|
898
1020
|
archiveSuggestion: archiveSuggestion,
|
|
899
1021
|
flagAsSpam: flagAsSpam,
|
|
1022
|
+
exportForCustomer: exportForCustomer,
|
|
1023
|
+
eraseForCustomer: eraseForCustomer,
|
|
900
1024
|
};
|
|
901
1025
|
}
|
|
902
1026
|
|
package/package.json
CHANGED