@blamejs/blamejs-shop 0.4.21 → 0.4.23
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 -0
- package/lib/admin.js +45 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +70 -0
- package/lib/compliance-export.js +61 -4
- package/lib/customer-portal.js +23 -0
- package/lib/customer-segments.js +8 -11
- package/lib/customers.js +72 -0
- package/lib/email-campaigns.js +8 -1
- package/lib/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +166 -6
- package/lib/order-export.js +14 -17
- package/lib/payment.js +178 -69
- package/lib/security-middleware.js +13 -5
- package/lib/storefront.js +128 -4
- package/lib/support-tickets.js +113 -53
- 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.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.
|
|
12
|
+
|
|
13
|
+
- v0.4.22 (2026-06-06) — **Privacy export covers every table, erasure revokes the login, one-click unsubscribe actually works, and bounces feed the suppression list.** A privacy and email-integrity release. The data-subject export now includes reviews, consent history, wishlist, surveys, and recently-viewed data — with a manifest that makes any absent section visible. Erasing a customer also deletes their passkeys and social-login links and revokes live sessions, so a deleted account can no longer sign in. The one-click unsubscribe that mail clients invoke from their native button now works (it pointed at a route that didn't exist), a new intake endpoint feeds provider bounce and complaint webhooks into the marketing suppression list, support-ticket edge cases are fixed, and the CSV exports share one complete injection-neutralizer. **Added:** *Bounce and complaint webhook intake* — A new `POST /api/webhooks/mail-bounce` endpoint accepts bounce and complaint webhooks from Postmark, SES, or Resend (selectable per request). Spam complaints and permanently-dead addresses land on the marketing suppression list — transactional mail still flows — and campaign metrics gain the bounce events they were built to read. The endpoint is armed only when `MAIL_BOUNCE_SECRET` is set and the provider presents it in the `x-mail-bounce-secret` header; unconfigured, it answers 503 and accepts nothing. **Fixed:** *The data-subject export includes every table that holds the customer* — The export bundle silently omitted reviews, consent history, wishlist items, survey responses, and recently-viewed data. All five are now included, and the bundle carries a completeness manifest listing every section as exported, empty, or absent — an omission is visible rather than silent. · *Erasure revokes the customer's ability to sign in* — Deleting a customer scrubbed their profile but left passkeys, Google/Apple login links, and live sessions intact — the "deleted" account could sign right back in. Erasure now deletes the passkeys and social-login links, revokes live portal sessions, and tombstones the email lookup hash irreversibly, while the anonymized record survives for order-history integrity. · *One-click unsubscribe works from the mail client's native button* — The unsubscribe headers stamped on every campaign pointed at a route that didn't exist, and the unsubscribe endpoint read its token from the form body — which a mail client's native one-click POST never carries. The header now points at the real endpoint and the token rides the URL, so the RFC 8058 one-click flow a mail client fires actually unsubscribes. The browser confirmation page is unchanged. · *Support tickets: reopen on customer reply, atomic tags, honest first-response time* — A customer reply to a resolved ticket silently disappeared from every operator queue — it now reopens the ticket with a fresh activity timestamp. Concurrent tag edits no longer overwrite each other (tag changes are single-statement updates). And an internal-only operator note no longer counts as the first response — only a reply the customer can actually see stamps the first-response time. · *CSV exports share one complete injection neutralizer* — The order-export and segment-members CSV exports each carried their own formula-injection defense, and both missed the tab, carriage-return, newline, and pipe vectors. Both now compose the framework's CSV cell guard, which covers the full vector set — including signed numerics, which the previous neutralizers deliberately exempted.
|
|
14
|
+
|
|
11
15
|
- v0.4.21 (2026-06-06) — **Subscription changes reach the payment processor, smart collections match real products, refunds claw back earned points, and the remaining balance races are closed.** A correctness release across the commerce surfaces. Changing a subscription's quantity now updates the processor before the local record — what the customer sees is what they're billed — and a cadence change on a processor-backed plan is honestly refused rather than silently ignored. Smart collections, which matched zero products in production because their rules read fields the catalog rows never carried, now evaluate against the real product data. Loyalty points earned on a purchase are reversed when the order is refunded or cancelled. And the loyalty, store-credit, gift-registry, and gift-card-ledger write paths that could lose updates or oversell under concurrency now use atomic guards. **Fixed:** *Subscription quantity changes reach the payment processor* — Changing a subscription's quantity updated the local record and showed a success message while the processor kept billing the original amount. The change is now pushed to the processor first — a processor failure leaves the local record untouched and surfaces the error, so the customer-visible state and the billed state can no longer diverge. Changing delivery frequency on a processor-backed subscription is refused with honest guidance (the billing cadence is bound to the plan's price and cannot be re-cadenced in place); self-managed local subscriptions keep their frequency controls. Self-manage controls also now respect the processor's status — a subscription the processor reports as cancelled or expired shows its state instead of live controls. · *Smart collections match real catalog products* — Smart-collection rules read fields like tags, price, and stock from each product row — fields the real catalog listing never carried, so every smart collection matched zero products in production even though admin previews built on richer mock rows looked right. Rule evaluation now joins the real tag, category, vendor, price, and inventory data onto each product page, and the admin preview routes through the same path the storefront uses. Smart-collection pages also stop re-walking the entire catalog on every paginated request — the matched set is briefly cached and a rule edit invalidates it. · *Refunds and cancellations reverse the loyalty points the order earned* — Points awarded when an order was paid survived a refund or cancellation — buying, earning, and refunding farmed points indefinitely. The earn record is now claimed atomically on the order's refund or cancel transition and the awarded points are clawed back from the balance, floored at zero when some were already spent. The reversal is idempotent (a re-delivered payment webhook reverses exactly once) and lifetime points — which drive tier — are deliberately untouched. · *Balance and inventory-adjacent races closed across loyalty, store credit, gift registry, and gift-card ledger* — Loyalty earn and adjust used read-then-write absolute updates that could lose concurrent updates; the gift-registry purchase check could oversell a registry item under two simultaneous purchases; and the gift-card ledger's overdraft check could let two concurrent debits both pass. All of these now use single-statement conditional writes that refuse cleanly when the guard fails. Store-credit expiry sweeps also stop under-expiring when operator-initiated deductions exist — the sweep now keys on its own prior output rather than netting all expiry rows together. · *Search-ranking click-through metrics are bounded and attributed* — The ranking metrics screen could show click-through rates above 100%, and clicks were attributed from the query string alone — trivially spoofable. A click now only counts when the same session recorded a real impression for that query, and displayed rates are bounded at 100%.
|
|
12
16
|
|
|
13
17
|
- v0.4.20 (2026-06-06) — **Payment integrity: refunds, gift-card balances, and checkout submission are race-proof, and abandoned checkouts return gift-card funds.** A money-path hardening release. Concurrent refund clicks on the same return can no longer reach the payment provider twice. A gift card that part-paid a checkout gets its balance back when the order is cancelled, fails payment, or is reaped as stale. Double-submitting checkout can no longer create two charges and two orders. Return requests are refused server-side on orders that aren't in a returnable state, the refund confirmation screen shows the currency the provider will actually refund, and a buy-online-pickup-in-store order now reaches its delivered state when picked up. **Fixed:** *A return can only be refunded once, even under concurrent requests* — Two simultaneous refund requests for the same approved return could both pass the status check and both reach the payment provider, each with a fresh idempotency key — a double refund. The refund now claims the return atomically before the provider is called (the second request answers 409), the provider call uses a key derived from the return itself so even a retry collapses into the same refund, and a provider failure releases the claim so the refund can be retried. · *Gift-card funds come back when a part-paid checkout dies* — When a gift card partially covered a checkout and the order was then cancelled, failed payment, or sat abandoned until reaped, the card's debit was never returned — the balance was permanently gone. Redemptions are now reversed on those order transitions: the card balance is restored (a fully drained card is reactivated), the reversal is idempotent, and it rides the same order lifecycle that releases inventory holds. · *Double-submitting checkout creates one charge and one order* — Two concurrent checkout submissions could both pass the cart-status read and each create a payment intent and an order. The cart is now claimed atomically as the single gate — the second submission is redirected back to the cart — and the payment-provider idempotency key is derived from the cart, so even a duplicate that slipped through would collapse into one charge on the provider's side. A failure before the order exists releases the claim so the customer can retry. · *Return requests are validated server-side* — The return-request form and its submission are now refused on orders that are not in a returnable state — a refunded, cancelled, or unpaid order answers with a clear message instead of opening a return that could feed a second refund downstream. Previously only the button's visibility enforced this. · *A failed gift-card settlement no longer strands a completed payment* — If recording a gift card's redemption failed after the payment had already succeeded and the order existed, the checkout aborted mid-way — leaving a paid order with a cart that never converted. The settlement step now completes the order regardless and surfaces the failure for follow-up instead of stranding the purchase. · *The refund confirmation shows the currency the provider refunds* — The confirmation screen displayed the return's approved currency, but the provider refunds in the original charge's currency. The screen now reads the same source the refund uses. · *Pickup orders reach their delivered state* — Marking a pickup order as picked up drove an order transition that was only legal for shipped orders, and the failure was silently swallowed — the pickup schedule said picked up while the order stayed in its paid state. The order lifecycle now has a pickup-completion transition, so a picked-up order genuinely lands in delivered and downstream reporting sees it.
|
package/README.md
CHANGED
|
@@ -204,6 +204,7 @@ variables. A signed-in operator can see the live on/off status of each at
|
|
|
204
204
|
| **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
|
|
205
205
|
| **Transactional email (SMTP)** | Order/ship/refund mail, abandoned-cart recovery, back-in-stock alerts, **wishlist sale + restock alerts and the periodic wishlist digest** (opt-in per customer on `/account/wishlist`), and **email magic-link sign-in** (a *Email me a sign-in link* option on `/account/login` for shoppers without a passkey or social login). | `SMTP_HOST`, `MAIL_FROM` (plus optional `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS`) | Without a mailer these surfaces stay inert — the wishlist crons scan nothing, the magic-link page reports email sign-in unavailable, and passkey / social login are unchanged. **Wishlist alerts + digests are additionally gated on an email-address resolver:** the customer store keeps only a salted email *hash* (never the plaintext), so out of the box there is no deliverable address and the crons send nothing even with SMTP set. They begin sending once you supply a resolver that maps a customer id to a deliverable address from your own plaintext-address store — the same hook abandoned-cart recovery uses. |
|
|
206
206
|
|
|
207
|
+
| **Email bounce / complaint intake** | Hard bounces and spam complaints from your email provider land on the marketing suppression list automatically (and backfill campaign metrics), so broadcasts stop mailing dead or complaining addresses. | `MAIL_BOUNCE_SECRET` (the value your provider sends in an `x-mail-bounce-secret` header), optional `MAIL_BOUNCE_VENDOR` (`postmark`\|`ses`\|`resend`, default `postmark`); point the provider's bounce/complaint webhook at `POST /api/webhooks/mail-bounce` (a `?vendor=` query overrides per request) | Without the secret the endpoint answers 503 — it never accepts an unauthenticated bounce. Complaints and hard bounces suppress at marketing scope (transactional mail still flows); soft bounces only backfill metrics. |
|
|
207
208
|
**Planned / not available:**
|
|
208
209
|
|
|
209
210
|
- **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
|
package/lib/admin.js
CHANGED
|
@@ -12909,6 +12909,51 @@ function mount(router, deps) {
|
|
|
12909
12909
|
},
|
|
12910
12910
|
));
|
|
12911
12911
|
|
|
12912
|
+
// Operator audit-chain integrity check (ops/tooling, bearer JSON).
|
|
12913
|
+
// Walks the hash linkage (verifyChain) AND re-derives every signed
|
|
12914
|
+
// checkpoint (verifyCheckpoints) so an operator can confirm the chain
|
|
12915
|
+
// is both internally consistent AND anchored — the checkpoint layer is
|
|
12916
|
+
// what catches a full-chain rewrite the hash linkage alone can't.
|
|
12917
|
+
// Read-only: any failure to read degrades to an unavailable verdict,
|
|
12918
|
+
// never a 500. Mounts only when the audit log is wired.
|
|
12919
|
+
if (operatorAuditLog && typeof operatorAuditLog.verifyChain === "function") {
|
|
12920
|
+
router.get("/admin/operators/audit/verify", _pageOrApi(true,
|
|
12921
|
+
R(async function (req, res) {
|
|
12922
|
+
var chain = null;
|
|
12923
|
+
var checkpoints = null;
|
|
12924
|
+
try { chain = await operatorAuditLog.verifyChain(); }
|
|
12925
|
+
catch (_e) { chain = { ok: false, reason: "verify-unavailable" }; }
|
|
12926
|
+
if (typeof operatorAuditLog.verifyCheckpoints === "function") {
|
|
12927
|
+
try { checkpoints = await operatorAuditLog.verifyCheckpoints(); }
|
|
12928
|
+
catch (_e) { checkpoints = { ok: false, reason: "verify-unavailable" }; }
|
|
12929
|
+
}
|
|
12930
|
+
_json(res, 200, {
|
|
12931
|
+
chain: chain,
|
|
12932
|
+
checkpoints: checkpoints,
|
|
12933
|
+
signing_available: !!(operatorAuditLog.signingAvailable && operatorAuditLog.signingAvailable()),
|
|
12934
|
+
});
|
|
12935
|
+
return false;
|
|
12936
|
+
}),
|
|
12937
|
+
// No dedicated console screen — the operators page links here for
|
|
12938
|
+
// tooling; a browser hit gets the same JSON verdict.
|
|
12939
|
+
async function (req, res) {
|
|
12940
|
+
var chain = null;
|
|
12941
|
+
var checkpoints = null;
|
|
12942
|
+
try { chain = await operatorAuditLog.verifyChain(); }
|
|
12943
|
+
catch (_e) { chain = { ok: false, reason: "verify-unavailable" }; }
|
|
12944
|
+
if (typeof operatorAuditLog.verifyCheckpoints === "function") {
|
|
12945
|
+
try { checkpoints = await operatorAuditLog.verifyCheckpoints(); }
|
|
12946
|
+
catch (_e) { checkpoints = { ok: false, reason: "verify-unavailable" }; }
|
|
12947
|
+
}
|
|
12948
|
+
_json(res, 200, {
|
|
12949
|
+
chain: chain,
|
|
12950
|
+
checkpoints: checkpoints,
|
|
12951
|
+
signing_available: !!(operatorAuditLog.signingAvailable && operatorAuditLog.signingAvailable()),
|
|
12952
|
+
});
|
|
12953
|
+
},
|
|
12954
|
+
));
|
|
12955
|
+
}
|
|
12956
|
+
|
|
12912
12957
|
// Create an operator. The first operator is created by the ADMIN_API_KEY
|
|
12913
12958
|
// owner; thereafter any owner can. `created_by` is the acting operator's
|
|
12914
12959
|
// id (or the "owner" sentinel for the break-glass key).
|
package/lib/asset-manifest.json
CHANGED
package/lib/checkout.js
CHANGED
|
@@ -246,6 +246,60 @@ function create(deps) {
|
|
|
246
246
|
var backorder = deps.backorder || null;
|
|
247
247
|
var preorder = deps.preorder || null;
|
|
248
248
|
|
|
249
|
+
// Optional inbound-webhook replay defense. A validly-signed Stripe event
|
|
250
|
+
// can be replayed verbatim inside the ±5-minute signature tolerance: the
|
|
251
|
+
// signature still verifies, so signature-checking alone does not stop a
|
|
252
|
+
// replay, and the downstream order-state idempotency is keyed on order
|
|
253
|
+
// state (not event identity) so a refund/cancel replay or a replay that
|
|
254
|
+
// races the first delivery slips past it. When `webhookReplayQuery` (a
|
|
255
|
+
// D1 query fn) is wired, every verified Stripe event id is atomically
|
|
256
|
+
// recorded with an INSERT ... ON CONFLICT DO NOTHING; a replay loses the
|
|
257
|
+
// PRIMARY-KEY race and is treated as an already-processed no-op. The
|
|
258
|
+
// store composes b.nonceStore over a D1-backed atomic backend rather
|
|
259
|
+
// than a hand-rolled has/set (which would race). Absent — the handler is
|
|
260
|
+
// byte-identical to the un-wired flow (order-state idempotency still
|
|
261
|
+
// covers the common re-delivery), so this is additive, never required.
|
|
262
|
+
var webhookReplayQuery = (typeof deps.webhookReplayQuery === "function")
|
|
263
|
+
? deps.webhookReplayQuery : null;
|
|
264
|
+
var STRIPE_REPLAY_TTL_MS = b.constants.TIME.minutes(5); // matches the signature tolerance window
|
|
265
|
+
var _stripeReplayStore = null;
|
|
266
|
+
function _stripeReplay() {
|
|
267
|
+
if (!webhookReplayQuery) return null;
|
|
268
|
+
if (!_stripeReplayStore) {
|
|
269
|
+
// Custom b.nonceStore backend: the atomicity lives in the D1
|
|
270
|
+
// INSERT ... ON CONFLICT DO NOTHING (the PRIMARY KEY race decides
|
|
271
|
+
// first-seen vs replay), so the check + insert can never interleave.
|
|
272
|
+
_stripeReplayStore = b.nonceStore.create({
|
|
273
|
+
backend: {
|
|
274
|
+
checkAndInsert: async function (eventId, expireAt) {
|
|
275
|
+
var nowMs = Date.now();
|
|
276
|
+
var r = await webhookReplayQuery(
|
|
277
|
+
"INSERT INTO stripe_webhook_events (event_id, first_seen_at, expires_at) " +
|
|
278
|
+
"VALUES (?1, ?2, ?3) ON CONFLICT (event_id) DO NOTHING",
|
|
279
|
+
[eventId, nowMs, expireAt],
|
|
280
|
+
);
|
|
281
|
+
// rowCount/changes === 1 → first sighting (recorded); 0 → replay.
|
|
282
|
+
var changes = (r && r.meta && typeof r.meta.changes === "number") ? r.meta.changes
|
|
283
|
+
: (r && typeof r.rowCount === "number") ? r.rowCount
|
|
284
|
+
: (r && typeof r.changes === "number") ? r.changes : 0;
|
|
285
|
+
return changes > 0;
|
|
286
|
+
},
|
|
287
|
+
purgeExpired: async function () {
|
|
288
|
+
var r = await webhookReplayQuery(
|
|
289
|
+
"DELETE FROM stripe_webhook_events WHERE expires_at < ?1",
|
|
290
|
+
[Date.now()],
|
|
291
|
+
);
|
|
292
|
+
if (r && r.meta && typeof r.meta.changes === "number") return r.meta.changes;
|
|
293
|
+
if (r && typeof r.rowCount === "number") return r.rowCount;
|
|
294
|
+
if (r && typeof r.changes === "number") return r.changes;
|
|
295
|
+
return 0;
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return _stripeReplayStore;
|
|
301
|
+
}
|
|
302
|
+
|
|
249
303
|
// Reprice a list of cart lines through the quantity-discount engine.
|
|
250
304
|
// Returns a shallow copy with `unit_amount_minor` overwritten by the
|
|
251
305
|
// discounted unit for each line's SKU at its quantity. A line whose
|
|
@@ -1261,6 +1315,22 @@ function create(deps) {
|
|
|
1261
1315
|
var event = v.event;
|
|
1262
1316
|
var eventType = event && event.type;
|
|
1263
1317
|
|
|
1318
|
+
// Replay defense — atomically claim this event id the moment the
|
|
1319
|
+
// signature verifies, BEFORE any subscription routing or state
|
|
1320
|
+
// transition. A replayed (already-seen) event id loses the
|
|
1321
|
+
// PRIMARY-KEY race and short-circuits to a processed no-op so no
|
|
1322
|
+
// transition, refund-mirror, or subscription update runs twice. A
|
|
1323
|
+
// store error fails CLOSED inside the nonceStore (returns
|
|
1324
|
+
// not-fresh) — a replay is indistinguishable from a wiped store, so
|
|
1325
|
+
// refusing is the safe default. No-op when the store isn't wired.
|
|
1326
|
+
var replay = _stripeReplay();
|
|
1327
|
+
if (replay && event && typeof event.id === "string" && event.id.length > 0) {
|
|
1328
|
+
var fresh = await replay.checkAndInsert(event.id, Date.now() + STRIPE_REPLAY_TTL_MS);
|
|
1329
|
+
if (!fresh) {
|
|
1330
|
+
return { handled: true, event_type: eventType || null, skipped: "replay", event_id: event.id };
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1264
1334
|
// Subscription events route to the subscriptions primitive
|
|
1265
1335
|
// (if wired). The one-time-order PaymentIntent path below
|
|
1266
1336
|
// stays unchanged.
|
package/lib/compliance-export.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* me" (export) or "erase everything you hold on me" (deletion).
|
|
11
11
|
* The primitive owns the request lifecycle + composes per-domain
|
|
12
12
|
* readers (customers, order, order-notes, subscriptions,
|
|
13
|
-
* addresses, payment-methods, support-tickets, loyalty
|
|
14
|
-
*
|
|
13
|
+
* addresses, payment-methods, support-tickets, loyalty, reviews,
|
|
14
|
+
* consent ledger, wishlist, surveys, recently-viewed) to assemble
|
|
15
|
+
* the bundle — every table that keys a row by the customer, so the
|
|
16
|
+
* export holds the whole record, not just the order/identity core.
|
|
17
|
+
* Delivery (email / signed URL / secure
|
|
15
18
|
* download portal) is the operator worker's concern — this
|
|
16
19
|
* primitive returns the bundle as structured JSON and stamps
|
|
17
20
|
* the lifecycle row when the worker confirms dispatch.
|
|
@@ -76,7 +79,11 @@
|
|
|
76
79
|
*
|
|
77
80
|
* Scope semantics on export:
|
|
78
81
|
*
|
|
79
|
-
* - `full` — every injected reader contributes
|
|
82
|
+
* - `full` — every injected reader contributes (identity,
|
|
83
|
+
* orders, subscriptions, addresses, payment
|
|
84
|
+
* methods, support tickets, loyalty, reviews,
|
|
85
|
+
* consent ledger, wishlist, surveys,
|
|
86
|
+
* recently-viewed).
|
|
80
87
|
* - `orders_only` — only `order` + `orderNotes` contribute;
|
|
81
88
|
* identity / loyalty / subscriptions /
|
|
82
89
|
* addresses / payment methods / support
|
|
@@ -92,7 +99,9 @@
|
|
|
92
99
|
*
|
|
93
100
|
* @primitive complianceExport
|
|
94
101
|
* @related customers, order, orderNotes, subscriptions, addresses,
|
|
95
|
-
* paymentMethods, supportTickets, loyalty,
|
|
102
|
+
* paymentMethods, supportTickets, loyalty, reviews,
|
|
103
|
+
* consentLedger, wishlist, customerSurveys, recentlyViewed,
|
|
104
|
+
* orderExport
|
|
96
105
|
*/
|
|
97
106
|
|
|
98
107
|
var b = require("./vendor/blamejs");
|
|
@@ -124,10 +133,19 @@ var ZERO_WIDTH_RE = new RegExp(
|
|
|
124
133
|
|
|
125
134
|
// Scope -> which injected readers contribute on export. Keeps the
|
|
126
135
|
// fulfillRequest logic a single lookup instead of nested if-else.
|
|
136
|
+
//
|
|
137
|
+
// `full` enumerates every domain that keys a row by the customer
|
|
138
|
+
// (directly via customer_id, or via a per-customer hash) so a
|
|
139
|
+
// subject-access export holds everything the controller stores about
|
|
140
|
+
// the person — not just the order / identity core. A domain that
|
|
141
|
+
// isn't wired (or whose reader is absent) is reported in
|
|
142
|
+
// `sections_absent`, so an unexported table is always visible in the
|
|
143
|
+
// bundle manifest rather than silently dropped.
|
|
127
144
|
var SCOPE_SECTIONS = Object.freeze({
|
|
128
145
|
full: Object.freeze([
|
|
129
146
|
"customers", "addresses", "order", "orderNotes",
|
|
130
147
|
"subscriptions", "paymentMethods", "supportTickets", "loyalty",
|
|
148
|
+
"reviews", "consentLedger", "wishlist", "surveys", "recentlyViewed",
|
|
131
149
|
]),
|
|
132
150
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
133
151
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -220,6 +238,28 @@ function _limit(n) {
|
|
|
220
238
|
|
|
221
239
|
function _now() { return Date.now(); }
|
|
222
240
|
|
|
241
|
+
// Classify a reader's returned section for the completeness manifest.
|
|
242
|
+
// A section is "empty" when the reader is wired but holds nothing for
|
|
243
|
+
// this customer: null, an empty array, or an object whose own values
|
|
244
|
+
// are all themselves empty (e.g. `{ balance: 0, history: [] }`). Any
|
|
245
|
+
// other shape (a non-empty array, an object carrying a row, a scalar)
|
|
246
|
+
// counts as "exported". The distinction lets the auditor tell "we hold
|
|
247
|
+
// nothing here" apart from "this is real PII we exported".
|
|
248
|
+
function _isEmptySection(section) {
|
|
249
|
+
if (section == null) return true;
|
|
250
|
+
if (Array.isArray(section)) return section.length === 0;
|
|
251
|
+
if (typeof section === "object") {
|
|
252
|
+
var keys = Object.keys(section);
|
|
253
|
+
if (keys.length === 0) return true;
|
|
254
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
255
|
+
if (!_isEmptySection(section[keys[i]])) return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
// A scalar (string / number / boolean) is real exported data.
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
223
263
|
// ---- row hydration ------------------------------------------------------
|
|
224
264
|
|
|
225
265
|
function _hydrate(r) {
|
|
@@ -267,6 +307,11 @@ function create(opts) {
|
|
|
267
307
|
paymentMethods: opts.paymentMethods || null,
|
|
268
308
|
supportTickets: opts.supportTickets || null,
|
|
269
309
|
loyalty: opts.loyalty || null,
|
|
310
|
+
reviews: opts.reviews || null,
|
|
311
|
+
consentLedger: opts.consentLedger || null,
|
|
312
|
+
wishlist: opts.wishlist || null,
|
|
313
|
+
surveys: opts.surveys || null,
|
|
314
|
+
recentlyViewed: opts.recentlyViewed || null,
|
|
270
315
|
};
|
|
271
316
|
|
|
272
317
|
// ---- requestExport -------------------------------------------------
|
|
@@ -391,12 +436,21 @@ function create(opts) {
|
|
|
391
436
|
var bundle = {};
|
|
392
437
|
var sectionsPresent = [];
|
|
393
438
|
var sectionsAbsent = [];
|
|
439
|
+
// Per-section completeness manifest. Every scope section lands here
|
|
440
|
+
// with an explicit status — "exported" (reader wired + returned
|
|
441
|
+
// data), "empty" (reader wired but the customer has no rows here),
|
|
442
|
+
// or "absent" (no reader wired at fulfillment time). An auditor (or
|
|
443
|
+
// the data subject) reads this to confirm the bundle isn't
|
|
444
|
+
// surreptitiously incomplete: an unexported table is visible as
|
|
445
|
+
// `absent`, never silently dropped.
|
|
446
|
+
var manifest = [];
|
|
394
447
|
|
|
395
448
|
for (var s = 0; s < sections.length; s += 1) {
|
|
396
449
|
var sectionName = sections[s];
|
|
397
450
|
var reader = injectedReaders[sectionName];
|
|
398
451
|
if (!reader || typeof reader.forCustomerExport !== "function") {
|
|
399
452
|
sectionsAbsent.push(sectionName);
|
|
453
|
+
manifest.push({ section: sectionName, status: "absent" });
|
|
400
454
|
continue;
|
|
401
455
|
}
|
|
402
456
|
// The reader returns whatever shape it owns (array of rows,
|
|
@@ -406,6 +460,7 @@ function create(opts) {
|
|
|
406
460
|
var section = await reader.forCustomerExport(row.customer_id);
|
|
407
461
|
bundle[sectionName] = section == null ? null : section;
|
|
408
462
|
sectionsPresent.push(sectionName);
|
|
463
|
+
manifest.push({ section: sectionName, status: _isEmptySection(section) ? "empty" : "exported" });
|
|
409
464
|
}
|
|
410
465
|
|
|
411
466
|
var fulfilledAt = _now();
|
|
@@ -422,6 +477,7 @@ function create(opts) {
|
|
|
422
477
|
fulfilled_at: fulfilledAt,
|
|
423
478
|
sections_present: sectionsPresent,
|
|
424
479
|
sections_absent: sectionsAbsent,
|
|
480
|
+
manifest: manifest,
|
|
425
481
|
data: bundle,
|
|
426
482
|
};
|
|
427
483
|
}
|
|
@@ -504,6 +560,7 @@ function create(opts) {
|
|
|
504
560
|
}
|
|
505
561
|
|
|
506
562
|
var domainOrder = [
|
|
563
|
+
"recentlyViewed", "wishlist", "surveys", "reviews", "consentLedger",
|
|
507
564
|
"supportTickets", "orderNotes", "order", "subscriptions",
|
|
508
565
|
"paymentMethods", "loyalty", "addresses", "customers",
|
|
509
566
|
];
|
package/lib/customer-portal.js
CHANGED
|
@@ -287,6 +287,29 @@ function create(opts) {
|
|
|
287
287
|
return { revoked: Number(r.rowCount || 0) > 0 };
|
|
288
288
|
},
|
|
289
289
|
|
|
290
|
+
// Bulk eject every LIVE (issued) session for one customer in a
|
|
291
|
+
// single statement. The right-to-erasure / password-reset hammer:
|
|
292
|
+
// where `revokeSession` kills one id, this kills the whole live set
|
|
293
|
+
// so a deleted (or compromised) customer's outstanding portal links
|
|
294
|
+
// all stop working at once. Idempotent — a customer with no issued
|
|
295
|
+
// session flips zero rows. Only `issued` rows move (consumed /
|
|
296
|
+
// expired / already-revoked stay terminal). Returns the count.
|
|
297
|
+
revokeAllForCustomer: async function (customerId, reason) {
|
|
298
|
+
var cid = _uuid(customerId, "customer_id");
|
|
299
|
+
var clean = _optShortString(reason, "reason", MAX_REASON_LEN);
|
|
300
|
+
if (clean == null) {
|
|
301
|
+
throw new TypeError("customer-portal.revokeAllForCustomer: reason required (non-empty string ≤ " + MAX_REASON_LEN + " chars)");
|
|
302
|
+
}
|
|
303
|
+
var now = _now();
|
|
304
|
+
var r = await query(
|
|
305
|
+
"UPDATE customer_portal_sessions " +
|
|
306
|
+
"SET status = 'revoked', revoked_at = ?1, revoke_reason = ?2 " +
|
|
307
|
+
"WHERE customer_id = ?3 AND status = 'issued'",
|
|
308
|
+
[now, clean, cid],
|
|
309
|
+
);
|
|
310
|
+
return { revoked: Number(r.rowCount || 0) };
|
|
311
|
+
},
|
|
312
|
+
|
|
290
313
|
// Audit / debugging entry point. Returns every session ever
|
|
291
314
|
// minted for a customer, newest-first by created_at (id as
|
|
292
315
|
// tiebreak — both are uuid.v7-derived so the ordering is
|
package/lib/customer-segments.js
CHANGED
|
@@ -332,10 +332,13 @@ function _now() { return Date.now(); }
|
|
|
332
332
|
// ---- CSV helpers (members export) ---------------------------------------
|
|
333
333
|
//
|
|
334
334
|
// Same RFC-4180 quoting + spreadsheet-formula-injection neutralization
|
|
335
|
-
// the order-export primitive uses
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
335
|
+
// the order-export primitive uses, composing the SAME shared vendored
|
|
336
|
+
// neutralizer (b.guardCsv.escapeCell) so the two CSV surfaces can't drift:
|
|
337
|
+
// every cell is quoted, embedded `"` doubled, and a cell beginning with any
|
|
338
|
+
// formula-trigger char — `= + - @` plus the tab / CR / LF / pipe / full-
|
|
339
|
+
// width variants an `= + - @`-only check misses — gets a leading TAB so a
|
|
340
|
+
// spreadsheet treats it as text (a customer-controlled display_name is the
|
|
341
|
+
// injection vector here).
|
|
339
342
|
|
|
340
343
|
function _coerceCell(value) {
|
|
341
344
|
if (value == null) return "";
|
|
@@ -344,14 +347,8 @@ function _coerceCell(value) {
|
|
|
344
347
|
return JSON.stringify(value);
|
|
345
348
|
}
|
|
346
349
|
|
|
347
|
-
var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
|
|
348
|
-
|
|
349
350
|
function _neutralizeInjection(s) {
|
|
350
|
-
|
|
351
|
-
var first = s.charAt(0);
|
|
352
|
-
if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
|
|
353
|
-
if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
|
|
354
|
-
return "'" + s;
|
|
351
|
+
return b.guardCsv.escapeCell(s);
|
|
355
352
|
}
|
|
356
353
|
|
|
357
354
|
function _csvCell(value) {
|
package/lib/customers.js
CHANGED
|
@@ -535,6 +535,78 @@ function create(opts) {
|
|
|
535
535
|
return true;
|
|
536
536
|
},
|
|
537
537
|
|
|
538
|
+
// Right-to-erasure auth revocation. A subject-access deletion must
|
|
539
|
+
// leave the customer UNABLE TO SIGN BACK IN — anonymizing the profile
|
|
540
|
+
// row alone is not erasure if every login credential still resolves.
|
|
541
|
+
// This deletes the THREE durable sign-in credentials keyed to the
|
|
542
|
+
// customer and severs the magic-link lookup key in ONE call:
|
|
543
|
+
//
|
|
544
|
+
// * customer_passkeys — every enrolled WebAuthn authenticator
|
|
545
|
+
// (passkey sign-in resolves a credential
|
|
546
|
+
// to this id; gone, the assertion misses).
|
|
547
|
+
// * customer_oauth_identities — every federated link (Google / Apple
|
|
548
|
+
// OIDC resolves byOAuthIdentity to this
|
|
549
|
+
// id; gone, the federated sign-in creates
|
|
550
|
+
// a fresh unrelated account instead).
|
|
551
|
+
// * email_hash -> tombstone — the magic-link / guest-order-claim path
|
|
552
|
+
// resolves byEmailHash; overwriting the
|
|
553
|
+
// hash with a per-id, non-reversible
|
|
554
|
+
// tombstone means no future link for the
|
|
555
|
+
// erased address can ever resolve this row
|
|
556
|
+
// (the address itself is never stored, so
|
|
557
|
+
// there is no plaintext to scrub — only
|
|
558
|
+
// the lookup key to break).
|
|
559
|
+
//
|
|
560
|
+
// The sealed 14-day auth cookie is stateless (no server session row to
|
|
561
|
+
// kill), but with no credential left to re-mint it and no lookup key to
|
|
562
|
+
// re-issue a magic link, the account cannot be re-entered after the
|
|
563
|
+
// cookie expires; the live customer-portal sessions are revoked
|
|
564
|
+
// separately by the caller (customerPortal.revokeAllForCustomer). Each
|
|
565
|
+
// step is idempotent — re-running on an already-erased row deletes
|
|
566
|
+
// nothing more and returns zero counts. dry_run reports the counts the
|
|
567
|
+
// wet run WOULD remove without mutating, so the operator preview shows
|
|
568
|
+
// the blast radius. Returns `{ passkeys, oauth_identities, email_hash_cleared }`.
|
|
569
|
+
eraseAuthForCustomer: async function (id, opts) {
|
|
570
|
+
_uuid(id, "customer id");
|
|
571
|
+
var dryRun = !!(opts && opts.dry_run);
|
|
572
|
+
var pkRows = (await query(
|
|
573
|
+
"SELECT COUNT(*) AS n FROM customer_passkeys WHERE customer_id = ?1", [id],
|
|
574
|
+
)).rows[0];
|
|
575
|
+
var oaRows = (await query(
|
|
576
|
+
"SELECT COUNT(*) AS n FROM customer_oauth_identities WHERE customer_id = ?1", [id],
|
|
577
|
+
)).rows[0];
|
|
578
|
+
var existing = (await query("SELECT email_hash FROM customers WHERE id = ?1", [id])).rows[0];
|
|
579
|
+
var emailHashSet = !!(existing && existing.email_hash && String(existing.email_hash).indexOf("erased:") !== 0);
|
|
580
|
+
if (dryRun) {
|
|
581
|
+
return {
|
|
582
|
+
passkeys: pkRows ? Number(pkRows.n) : 0,
|
|
583
|
+
oauth_identities: oaRows ? Number(oaRows.n) : 0,
|
|
584
|
+
email_hash_cleared: emailHashSet ? 1 : 0,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
var ts = _now();
|
|
588
|
+
var pkDel = await query("DELETE FROM customer_passkeys WHERE customer_id = ?1", [id]);
|
|
589
|
+
var oaDel = await query("DELETE FROM customer_oauth_identities WHERE customer_id = ?1", [id]);
|
|
590
|
+
// Tombstone the lookup key with a per-id, non-reversible value that
|
|
591
|
+
// can never collide with a real namespaceHash digest (those are hex)
|
|
592
|
+
// and can never be re-derived from any email address. Only rewrite a
|
|
593
|
+
// live (non-tombstoned) hash so a re-run is a no-op.
|
|
594
|
+
var emailHashCleared = 0;
|
|
595
|
+
if (emailHashSet) {
|
|
596
|
+
var tombstone = "erased:" + b.crypto.namespaceHash("customer-erased-email", id);
|
|
597
|
+
var upd = await query(
|
|
598
|
+
"UPDATE customers SET email_hash = ?1, updated_at = ?2 WHERE id = ?3 AND email_hash = ?4",
|
|
599
|
+
[tombstone, ts, id, existing.email_hash],
|
|
600
|
+
);
|
|
601
|
+
emailHashCleared = Number((upd && upd.rowCount) || 0);
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
passkeys: Number((pkDel && pkDel.rowCount) || 0),
|
|
605
|
+
oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
|
|
606
|
+
email_hash_cleared: emailHashCleared,
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
|
|
538
610
|
// Mutate a customer's editable profile fields. v1 covers display_name
|
|
539
611
|
// only — the one field a customer can safely change without a
|
|
540
612
|
// verification round trip.
|
package/lib/email-campaigns.js
CHANGED
|
@@ -783,7 +783,14 @@ function create(opts) {
|
|
|
783
783
|
var signup = await newsletter.byEmailHash(emailHash);
|
|
784
784
|
if (!signup || !signup.id) return null;
|
|
785
785
|
var issued = await newsletter.issueUnsubscribeToken(signup.id);
|
|
786
|
-
|
|
786
|
+
// The storefront mounts the one-click unsubscribe at GET/POST
|
|
787
|
+
// /unsubscribe (lib/storefront.js + EDGE_POST_PATHS). The token rides
|
|
788
|
+
// in the URL — a mail client's RFC 8058 one-click POST fires at this
|
|
789
|
+
// exact URL with a `List-Unsubscribe=One-Click` body and no token of
|
|
790
|
+
// its own, so the route MUST be the real one and MUST read the token
|
|
791
|
+
// from here. (An earlier `/newsletter/unsubscribe` target had no route
|
|
792
|
+
// behind it — every native one-click POST 404'd and never unsubscribed.)
|
|
793
|
+
var url = unsubscribeBaseUrl + "/unsubscribe?token=" + encodeURIComponent(issued.token);
|
|
787
794
|
// Validate the header SHAPE through the vendored RFC 2369 + RFC 8058
|
|
788
795
|
// guard so a malformed link (non-https, control byte) never reaches
|
|
789
796
|
// the wire — Gmail / Yahoo refuse mail that carries a broken pair.
|
package/lib/operator-accounts.js
CHANGED
|
@@ -87,9 +87,60 @@
|
|
|
87
87
|
*
|
|
88
88
|
* Storage: `migrations-d1/0213_operator_accounts.sql`.
|
|
89
89
|
*
|
|
90
|
+
* Dual-control (two-operator approval) — evaluated, deliberately NOT
|
|
91
|
+
* wired in v1:
|
|
92
|
+
*
|
|
93
|
+
* `b.dualControl` raises a destructive op to "two distinct named
|
|
94
|
+
* operators must approve before it runs." It is the right control for
|
|
95
|
+
* a store run by a TEAM, but it is structurally a two-actor M-of-N
|
|
96
|
+
* gate — `create()` hard-validates `minApprovers >= 2`, and there is
|
|
97
|
+
* no auto-satisfy-on-single-operator path. The dominant deployment of
|
|
98
|
+
* this storefront is a SINGLE owner; on such a store a dual-control
|
|
99
|
+
* gate would DEADLOCK the destructive op forever — there is no second
|
|
100
|
+
* operator to approve, so the op can never consume its grant. Wiring
|
|
101
|
+
* it unconditionally is therefore a worse failure than the
|
|
102
|
+
* single-operator risk it would close. It is also a two-request async
|
|
103
|
+
* workflow (request → a different actor approves → consume), which
|
|
104
|
+
* does not fit the synchronous single-POST shape these admin actions
|
|
105
|
+
* have today.
|
|
106
|
+
*
|
|
107
|
+
* Per destructive op, the v1 stance:
|
|
108
|
+
*
|
|
109
|
+
* - gift-card void (POST /admin/gift-cards/:id/void): recoverable —
|
|
110
|
+
* void is a status flip that PRESERVES the balance (it burns no
|
|
111
|
+
* value), so re-issuing or restoring the card's status makes a
|
|
112
|
+
* mistaken void recoverable, and the giftcard ledger records the
|
|
113
|
+
* acting operator. The cost of a deadlock-on-single-owner
|
|
114
|
+
* outweighs the benefit. Stance: role-gate (`orders.write`) +
|
|
115
|
+
* audit, no dual-control.
|
|
116
|
+
*
|
|
117
|
+
* - refunds (POST /admin/orders/:id/refund, /admin/returns/:id/
|
|
118
|
+
* refund): real money out, but bounded by the order's captured
|
|
119
|
+
* amount (the payment primitive refuses to over-refund a PI), and
|
|
120
|
+
* every refund is idempotency-keyed + audited with the operator
|
|
121
|
+
* id. The damage ceiling is one order's total, not the whole
|
|
122
|
+
* book. Stance: role-gate (`orders.write`) + audit, no
|
|
123
|
+
* dual-control.
|
|
124
|
+
*
|
|
125
|
+
* - operator disable (POST /admin/operators/:id/disable): the
|
|
126
|
+
* highest-blast-radius op (it can revoke a co-owner), but it is
|
|
127
|
+
* REVERSIBLE in one click (`/enable`), gated on `operators.manage`
|
|
128
|
+
* (owner-only), and audited. A malicious self-lockout still leaves
|
|
129
|
+
* the `ADMIN_API_KEY` break-glass owner credential working.
|
|
130
|
+
* Stance: role-gate + audit, no dual-control.
|
|
131
|
+
*
|
|
132
|
+
* Re-open condition: when a store carries TWO OR MORE active `owner`/
|
|
133
|
+
* `manager` operators, dual-control becomes wire-able WITHOUT the
|
|
134
|
+
* deadlock risk — gate the approval flow on `listAccounts({ status:
|
|
135
|
+
* "active" })` length >= 2, auto-satisfy below that, and move the
|
|
136
|
+
* destructive POST to a request/approve/consume sequence keyed off a
|
|
137
|
+
* b.cache grant. That is the natural follow-up the moment a real
|
|
138
|
+
* multi-operator store exists; it is not defensible to ship a control
|
|
139
|
+
* that bricks the single-operator common case to pre-empt it.
|
|
140
|
+
*
|
|
90
141
|
* @primitive operatorAccounts
|
|
91
142
|
* @related operatorRoles, operatorSessions, operatorAuditLog,
|
|
92
|
-
* b.password, b.crypto, b.guardEmail, b.uuid
|
|
143
|
+
* b.password, b.crypto, b.guardEmail, b.uuid, b.dualControl
|
|
93
144
|
*/
|
|
94
145
|
|
|
95
146
|
var EMAIL_NAMESPACE = "operator-email";
|