@blamejs/blamejs-shop 0.0.53 → 0.0.56
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 +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
package/lib/webhooks.js
CHANGED
|
@@ -53,6 +53,38 @@ var KNOWN_EVENTS = Object.freeze([
|
|
|
53
53
|
|
|
54
54
|
var SECRET_BYTES = 32;
|
|
55
55
|
|
|
56
|
+
// Exponential backoff schedule (seconds) for failed deliveries.
|
|
57
|
+
//
|
|
58
|
+
// Budget rationale — the receiver can be down for a full day before
|
|
59
|
+
// we give up. The five-step ramp is deliberately conservative:
|
|
60
|
+
//
|
|
61
|
+
// 60s → cover a transient blip (LB reload, instance restart,
|
|
62
|
+
// garbage-collection pause). Quick retry catches the
|
|
63
|
+
// 99% of failures that resolve themselves in <1 minute.
|
|
64
|
+
// 300s → after the second failure assume the outage is real;
|
|
65
|
+
// back off to five minutes so we don't hammer a receiver
|
|
66
|
+
// whose maintenance window is mid-deploy.
|
|
67
|
+
// 1800s → thirty minutes — covers a typical incident-response
|
|
68
|
+
// rollback / hotfix-deploy turnaround.
|
|
69
|
+
// 14400s → four hours — covers an overnight outage where the
|
|
70
|
+
// on-call hasn't been paged yet.
|
|
71
|
+
// 86400s → one day — last attempt before DLQ. If a receiver is
|
|
72
|
+
// still down 24h after the first failure the operator
|
|
73
|
+
// needs to look at the row by hand; further retries
|
|
74
|
+
// would risk DOS'ing the receiver's recovery path.
|
|
75
|
+
//
|
|
76
|
+
// After the fifth failed attempt the delivery moves to webhook_dlq
|
|
77
|
+
// for operator investigation — see `_moveToDlq` below.
|
|
78
|
+
var BACKOFF_SCHEDULE_S = Object.freeze([60, 300, 1800, 14400, 86400]);
|
|
79
|
+
var MAX_ATTEMPTS = 5;
|
|
80
|
+
|
|
81
|
+
// Window used by the per-endpoint rate-limit sliding count. One
|
|
82
|
+
// minute is short enough that a wedged receiver doesn't permanently
|
|
83
|
+
// brick its own delivery feed (the window naturally slides forward),
|
|
84
|
+
// long enough that bursty fan-out from a single high-volume event
|
|
85
|
+
// doesn't trip the throttle on a healthy endpoint.
|
|
86
|
+
var RATE_WINDOW_MS = 60 * 1000;
|
|
87
|
+
|
|
56
88
|
function _now() { return Date.now(); }
|
|
57
89
|
|
|
58
90
|
function _validateUrl(url) {
|
|
@@ -120,10 +152,25 @@ function create(opts) {
|
|
|
120
152
|
}
|
|
121
153
|
var transport = typeof opts.transport === "function" ? opts.transport : _defaultTransport;
|
|
122
154
|
var retryOpts = opts.retry || { maxAttempts: 2, baseDelayMs: 25, maxDelayMs: 250 };
|
|
155
|
+
// Operator-injectable clock — tests fast-forward through the
|
|
156
|
+
// exponential backoff schedule by passing a synthetic `now()` that
|
|
157
|
+
// jumps ahead by the scheduled interval. Production callers leave
|
|
158
|
+
// it undefined and pick up the wall clock.
|
|
159
|
+
var nowFn = (typeof opts.now === "function") ? opts.now : _now;
|
|
123
160
|
|
|
124
161
|
async function _deliver(endpointRow, eventType, payloadJson) {
|
|
162
|
+
// Rate-limit gate — count deliveries created in the last 60s for
|
|
163
|
+
// this endpoint and refuse the new fan-out when the configured
|
|
164
|
+
// ceiling would be exceeded. The refusal persists a delivery row
|
|
165
|
+
// (last_error = "rate-limited", delivered_at = NULL, no
|
|
166
|
+
// next_retry_at) so the operator sees the throttle in the admin
|
|
167
|
+
// feed instead of silently dropping the event.
|
|
168
|
+
var rateOk = await _checkRateLimit(endpointRow);
|
|
169
|
+
if (!rateOk) {
|
|
170
|
+
return await _persistRateLimited(endpointRow, eventType, payloadJson);
|
|
171
|
+
}
|
|
125
172
|
var deliveryId = _b().uuid.v7();
|
|
126
|
-
var createdAt =
|
|
173
|
+
var createdAt = nowFn();
|
|
127
174
|
await query(
|
|
128
175
|
"INSERT INTO webhook_deliveries (id, endpoint_id, event_type, payload_json, attempts, created_at) " +
|
|
129
176
|
"VALUES (?1, ?2, ?3, ?4, 0, ?5)",
|
|
@@ -132,6 +179,31 @@ function create(opts) {
|
|
|
132
179
|
return await _attempt(deliveryId, endpointRow, eventType, payloadJson);
|
|
133
180
|
}
|
|
134
181
|
|
|
182
|
+
async function _checkRateLimit(endpointRow) {
|
|
183
|
+
var limit = endpointRow.rate_limit_per_minute;
|
|
184
|
+
if (typeof limit !== "number" || !isFinite(limit) || limit <= 0) return true;
|
|
185
|
+
var windowStart = nowFn() - RATE_WINDOW_MS;
|
|
186
|
+
var r = await query(
|
|
187
|
+
"SELECT count(*) AS n FROM webhook_deliveries WHERE endpoint_id = ?1 AND created_at > ?2",
|
|
188
|
+
[endpointRow.id, windowStart],
|
|
189
|
+
);
|
|
190
|
+
var n = (r.rows[0] && (r.rows[0].n != null ? r.rows[0].n : r.rows[0]["count(*)"])) || 0;
|
|
191
|
+
return n < limit;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function _persistRateLimited(endpointRow, eventType, payloadJson) {
|
|
195
|
+
var deliveryId = _b().uuid.v7();
|
|
196
|
+
var ts = nowFn();
|
|
197
|
+
await query(
|
|
198
|
+
"INSERT INTO webhook_deliveries " +
|
|
199
|
+
"(id, endpoint_id, event_type, payload_json, attempts, last_status, last_error, " +
|
|
200
|
+
" last_attempted_at, created_at) " +
|
|
201
|
+
"VALUES (?1, ?2, ?3, ?4, 0, NULL, ?5, ?6, ?7)",
|
|
202
|
+
[deliveryId, endpointRow.id, eventType, payloadJson, "rate-limited", ts, ts],
|
|
203
|
+
);
|
|
204
|
+
return await _getDelivery(deliveryId);
|
|
205
|
+
}
|
|
206
|
+
|
|
135
207
|
async function _attempt(deliveryId, endpointRow, eventType, payloadJson) {
|
|
136
208
|
var signer = _b().webhook.signer({
|
|
137
209
|
algo: "hmac-sha3-512",
|
|
@@ -157,24 +229,70 @@ function create(opts) {
|
|
|
157
229
|
errMsg = (e && e.message) || String(e);
|
|
158
230
|
}
|
|
159
231
|
|
|
160
|
-
var ts =
|
|
232
|
+
var ts = nowFn();
|
|
161
233
|
var delivered = (status >= 100 && status < 400);
|
|
162
234
|
if (delivered) {
|
|
163
235
|
await query(
|
|
164
|
-
"UPDATE webhook_deliveries SET attempts = attempts + 1, last_status = ?1,
|
|
236
|
+
"UPDATE webhook_deliveries SET attempts = attempts + 1, last_status = ?1, " +
|
|
237
|
+
"last_error = NULL, delivered_at = ?2, last_attempted_at = ?2, next_retry_at = NULL " +
|
|
165
238
|
"WHERE id = ?3",
|
|
166
239
|
[status, ts, deliveryId],
|
|
167
240
|
);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
241
|
+
return await _getDelivery(deliveryId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Failure path — increment attempts, record the error, then
|
|
245
|
+
// either schedule the next retry (attempts < MAX_ATTEMPTS) or
|
|
246
|
+
// move the delivery to webhook_dlq for operator review. The
|
|
247
|
+
// backoff index uses `attempts - 1` after increment so the first
|
|
248
|
+
// failure gets BACKOFF_SCHEDULE_S[0] (60s), the second gets [1]
|
|
249
|
+
// (300s), etc.
|
|
250
|
+
var current = await _getDelivery(deliveryId);
|
|
251
|
+
var nextAttempts = ((current && current.attempts) || 0) + 1;
|
|
252
|
+
var willDlq = (nextAttempts >= MAX_ATTEMPTS);
|
|
253
|
+
var nextRetryAt = null;
|
|
254
|
+
if (!willDlq) {
|
|
255
|
+
var idx = nextAttempts - 1;
|
|
256
|
+
if (idx < BACKOFF_SCHEDULE_S.length) {
|
|
257
|
+
nextRetryAt = ts + (BACKOFF_SCHEDULE_S[idx] * 1000);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
await query(
|
|
261
|
+
"UPDATE webhook_deliveries SET attempts = ?1, last_status = ?2, last_error = ?3, " +
|
|
262
|
+
"last_attempted_at = ?4, next_retry_at = ?5 WHERE id = ?6",
|
|
263
|
+
[nextAttempts, status || null, errMsg || ("http-" + status), ts, nextRetryAt, deliveryId],
|
|
264
|
+
);
|
|
265
|
+
if (willDlq) {
|
|
266
|
+
await _moveToDlq(deliveryId, endpointRow, eventType, payloadJson, nextAttempts,
|
|
267
|
+
status || null, errMsg || ("http-" + status), ts);
|
|
174
268
|
}
|
|
175
269
|
return await _getDelivery(deliveryId);
|
|
176
270
|
}
|
|
177
271
|
|
|
272
|
+
async function _moveToDlq(deliveryId, endpointRow, eventType, payloadJson, attempts,
|
|
273
|
+
lastStatus, lastError, ts) {
|
|
274
|
+
// The original delivery row stays in webhook_deliveries (with
|
|
275
|
+
// attempts === MAX_ATTEMPTS, delivered_at NULL) so the
|
|
276
|
+
// per-endpoint feed still surfaces the failure. The DLQ row
|
|
277
|
+
// carries the full payload so replayFromDlq can re-queue
|
|
278
|
+
// without consulting the original delivery.
|
|
279
|
+
var dlqId = _b().uuid.v7();
|
|
280
|
+
var firstAttemptedAt;
|
|
281
|
+
var r = await query(
|
|
282
|
+
"SELECT created_at FROM webhook_deliveries WHERE id = ?1",
|
|
283
|
+
[deliveryId],
|
|
284
|
+
);
|
|
285
|
+
firstAttemptedAt = (r.rows[0] && r.rows[0].created_at) || ts;
|
|
286
|
+
await query(
|
|
287
|
+
"INSERT INTO webhook_dlq " +
|
|
288
|
+
"(id, endpoint_id, delivery_id, event_type, payload_json, attempts, " +
|
|
289
|
+
" last_status, last_error, first_attempted_at, last_attempted_at, dropped_at) " +
|
|
290
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
291
|
+
[dlqId, endpointRow.id, deliveryId, eventType, payloadJson, attempts,
|
|
292
|
+
lastStatus, lastError, firstAttemptedAt, ts, ts],
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
178
296
|
async function _getDelivery(id) {
|
|
179
297
|
var r = await query("SELECT * FROM webhook_deliveries WHERE id = ?1", [id]);
|
|
180
298
|
return r.rows[0] || null;
|
|
@@ -196,10 +314,25 @@ function create(opts) {
|
|
|
196
314
|
var id = _b().uuid.v7();
|
|
197
315
|
var secret = _b().crypto.generateToken(SECRET_BYTES);
|
|
198
316
|
var ts = _now();
|
|
317
|
+
// rate_limit_per_minute defaults to 60 at the schema level
|
|
318
|
+
// (see migration 0017). Operators wiring a slow receiver opt
|
|
319
|
+
// into a lower ceiling on create; raising it past the default
|
|
320
|
+
// is supported but undocumented — most receivers handle 60
|
|
321
|
+
// req/min comfortably.
|
|
322
|
+
var rate = 60;
|
|
323
|
+
if (Object.prototype.hasOwnProperty.call(input, "rate_limit_per_minute")) {
|
|
324
|
+
if (typeof input.rate_limit_per_minute !== "number" ||
|
|
325
|
+
!Number.isInteger(input.rate_limit_per_minute) ||
|
|
326
|
+
input.rate_limit_per_minute <= 0) {
|
|
327
|
+
throw new TypeError("webhooks.endpoints.create: rate_limit_per_minute must be a positive integer");
|
|
328
|
+
}
|
|
329
|
+
rate = input.rate_limit_per_minute;
|
|
330
|
+
}
|
|
199
331
|
await query(
|
|
200
|
-
"INSERT INTO webhook_endpoints
|
|
201
|
-
"
|
|
202
|
-
|
|
332
|
+
"INSERT INTO webhook_endpoints " +
|
|
333
|
+
"(id, url, secret, events, active, rate_limit_per_minute, created_at, updated_at) " +
|
|
334
|
+
"VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?6)",
|
|
335
|
+
[id, input.url, secret, events, rate, ts],
|
|
203
336
|
);
|
|
204
337
|
return await _getEndpoint(id);
|
|
205
338
|
},
|
|
@@ -222,6 +355,7 @@ function create(opts) {
|
|
|
222
355
|
var nextUrl = current.url;
|
|
223
356
|
var nextEvents = current.events;
|
|
224
357
|
var nextActive = current.active;
|
|
358
|
+
var nextRate = current.rate_limit_per_minute;
|
|
225
359
|
if (Object.prototype.hasOwnProperty.call(patch, "url")) {
|
|
226
360
|
_validateUrl(patch.url);
|
|
227
361
|
nextUrl = patch.url;
|
|
@@ -235,10 +369,18 @@ function create(opts) {
|
|
|
235
369
|
}
|
|
236
370
|
nextActive = (patch.active === true || patch.active === 1) ? 1 : 0;
|
|
237
371
|
}
|
|
372
|
+
if (Object.prototype.hasOwnProperty.call(patch, "rate_limit_per_minute")) {
|
|
373
|
+
var rate = patch.rate_limit_per_minute;
|
|
374
|
+
if (typeof rate !== "number" || !Number.isInteger(rate) || rate <= 0) {
|
|
375
|
+
throw new TypeError("webhooks.endpoints.update: rate_limit_per_minute must be a positive integer");
|
|
376
|
+
}
|
|
377
|
+
nextRate = rate;
|
|
378
|
+
}
|
|
238
379
|
var ts = _now();
|
|
239
380
|
await query(
|
|
240
|
-
"UPDATE webhook_endpoints SET url = ?1, events = ?2, active = ?3,
|
|
241
|
-
|
|
381
|
+
"UPDATE webhook_endpoints SET url = ?1, events = ?2, active = ?3, " +
|
|
382
|
+
"rate_limit_per_minute = ?4, updated_at = ?5 WHERE id = ?6",
|
|
383
|
+
[nextUrl, nextEvents, nextActive, nextRate, ts, id],
|
|
242
384
|
);
|
|
243
385
|
return await _getEndpoint(id);
|
|
244
386
|
},
|
|
@@ -272,6 +414,139 @@ function create(opts) {
|
|
|
272
414
|
},
|
|
273
415
|
},
|
|
274
416
|
|
|
417
|
+
dlq: {
|
|
418
|
+
list: async function (endpointId, listOpts) {
|
|
419
|
+
_uuid(endpointId, "endpoint id");
|
|
420
|
+
var limit = (listOpts && Number.isInteger(listOpts.limit) && listOpts.limit > 0) ? listOpts.limit : 50;
|
|
421
|
+
if (limit > 500) limit = 500;
|
|
422
|
+
var r = await query(
|
|
423
|
+
"SELECT * FROM webhook_dlq WHERE endpoint_id = ?1 ORDER BY dropped_at DESC LIMIT ?2",
|
|
424
|
+
[endpointId, limit],
|
|
425
|
+
);
|
|
426
|
+
return r.rows;
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
get: async function (id) {
|
|
430
|
+
_uuid(id, "dlq id");
|
|
431
|
+
var r = await query("SELECT * FROM webhook_dlq WHERE id = ?1", [id]);
|
|
432
|
+
return r.rows[0] || null;
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
// Re-queue a DLQ row as a fresh delivery on its original
|
|
437
|
+
// endpoint. The DLQ row is removed once the new delivery row is
|
|
438
|
+
// persisted — keeping both would leave duplicate state for the
|
|
439
|
+
// admin feed to reconcile. The new delivery starts at attempts=0
|
|
440
|
+
// so the full backoff schedule applies if the receiver is still
|
|
441
|
+
// unhappy.
|
|
442
|
+
replayFromDlq: async function (dlqId) {
|
|
443
|
+
_uuid(dlqId, "dlq id");
|
|
444
|
+
var r = await query("SELECT * FROM webhook_dlq WHERE id = ?1", [dlqId]);
|
|
445
|
+
var row = r.rows[0];
|
|
446
|
+
if (!row) return null;
|
|
447
|
+
var endpoint = await _getEndpoint(row.endpoint_id);
|
|
448
|
+
if (!endpoint) return null;
|
|
449
|
+
// Delete first — if the subsequent _deliver fails we don't
|
|
450
|
+
// want a permanent ghost; the new delivery row carries the
|
|
451
|
+
// failure state forward and (on five failures) lands back in
|
|
452
|
+
// the DLQ as a fresh row.
|
|
453
|
+
await query("DELETE FROM webhook_dlq WHERE id = ?1", [dlqId]);
|
|
454
|
+
return await _deliver(endpoint, row.event_type, row.payload_json);
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// Polled scheduler entry point — picks deliveries whose
|
|
458
|
+
// next_retry_at has come due and re-attempts each. Operators
|
|
459
|
+
// wire this to a cron / Durable Object alarm / queue worker.
|
|
460
|
+
// Returns the list of deliveries the call attempted.
|
|
461
|
+
//
|
|
462
|
+
// No claim/lock here — the framework's single-writer assumption
|
|
463
|
+
// for the deliveries table holds (the worker is the only writer).
|
|
464
|
+
// A multi-writer deployment composes external locking before
|
|
465
|
+
// calling processRetries.
|
|
466
|
+
processRetries: async function (procOpts) {
|
|
467
|
+
var now = (procOpts && typeof procOpts.now === "number") ? procOpts.now : nowFn();
|
|
468
|
+
var max = (procOpts && Number.isInteger(procOpts.max) && procOpts.max > 0) ? procOpts.max : 100;
|
|
469
|
+
if (max > 1000) max = 1000;
|
|
470
|
+
var r = await query(
|
|
471
|
+
"SELECT * FROM webhook_deliveries " +
|
|
472
|
+
"WHERE next_retry_at IS NOT NULL AND next_retry_at <= ?1 AND delivered_at IS NULL " +
|
|
473
|
+
"ORDER BY next_retry_at ASC LIMIT ?2",
|
|
474
|
+
[now, max],
|
|
475
|
+
);
|
|
476
|
+
var attempted = [];
|
|
477
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
478
|
+
var row = r.rows[i];
|
|
479
|
+
var endpoint = await _getEndpoint(row.endpoint_id);
|
|
480
|
+
if (!endpoint) continue;
|
|
481
|
+
var d = await _attempt(row.id, endpoint, row.event_type, row.payload_json);
|
|
482
|
+
attempted.push(d);
|
|
483
|
+
}
|
|
484
|
+
return attempted;
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// Inbound signature verification — operators receiving webhooks
|
|
488
|
+
// from peers (or replaying their own outbound for end-to-end
|
|
489
|
+
// testing) call this to validate the Stripe-shaped header
|
|
490
|
+
// `t=<unix>,v1=<hmac-sha256(secret, ts + "." + body)>` before
|
|
491
|
+
// trusting the payload. Delegates to b.webhook.verify — the
|
|
492
|
+
// framework's Stripe-compatible verifier handles tolerance,
|
|
493
|
+
// constant-time compare, and HMAC-SHA-256 computation.
|
|
494
|
+
//
|
|
495
|
+
// `toleranceSeconds` defaults to 300 (5 min) to match the
|
|
496
|
+
// framework default; the floor is 30 s — anything shorter risks
|
|
497
|
+
// false rejects from clock skew.
|
|
498
|
+
verifyIncoming: async function (payload, signatureHeader, secret, toleranceSeconds) {
|
|
499
|
+
if (payload == null) {
|
|
500
|
+
throw new TypeError("webhooks.verifyIncoming: payload required");
|
|
501
|
+
}
|
|
502
|
+
if (typeof signatureHeader !== "string" || signatureHeader.length === 0) {
|
|
503
|
+
throw new TypeError("webhooks.verifyIncoming: signatureHeader must be a non-empty string");
|
|
504
|
+
}
|
|
505
|
+
if ((typeof secret !== "string" || secret.length === 0) && !Buffer.isBuffer(secret)) {
|
|
506
|
+
throw new TypeError("webhooks.verifyIncoming: secret must be a non-empty string or Buffer");
|
|
507
|
+
}
|
|
508
|
+
var tolMs;
|
|
509
|
+
if (toleranceSeconds === undefined) {
|
|
510
|
+
tolMs = 300 * 1000;
|
|
511
|
+
} else {
|
|
512
|
+
if (typeof toleranceSeconds !== "number" || !isFinite(toleranceSeconds) || toleranceSeconds < 30) {
|
|
513
|
+
throw new TypeError("webhooks.verifyIncoming: toleranceSeconds must be >= 30");
|
|
514
|
+
}
|
|
515
|
+
tolMs = Math.floor(toleranceSeconds * 1000);
|
|
516
|
+
}
|
|
517
|
+
return await _b().webhook.verify({
|
|
518
|
+
alg: "hmac-sha256-stripe",
|
|
519
|
+
secret: secret,
|
|
520
|
+
header: signatureHeader,
|
|
521
|
+
body: payload,
|
|
522
|
+
toleranceMs: tolMs,
|
|
523
|
+
});
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
// Companion to verifyIncoming — produce the Stripe-shaped
|
|
527
|
+
// `t=<unix>,v1=<hex>` header for a given payload + secret. The
|
|
528
|
+
// round-trip is what end-to-end tests + downstream emitters
|
|
529
|
+
// need; operators forwarding events to peer systems use this to
|
|
530
|
+
// sign the relay body.
|
|
531
|
+
signOutgoing: function (payload, secret, timestampSeconds) {
|
|
532
|
+
if (payload == null) {
|
|
533
|
+
throw new TypeError("webhooks.signOutgoing: payload required");
|
|
534
|
+
}
|
|
535
|
+
if ((typeof secret !== "string" || secret.length === 0) && !Buffer.isBuffer(secret)) {
|
|
536
|
+
throw new TypeError("webhooks.signOutgoing: secret must be a non-empty string or Buffer");
|
|
537
|
+
}
|
|
538
|
+
var input = {
|
|
539
|
+
alg: "hmac-sha256-stripe",
|
|
540
|
+
secret: secret,
|
|
541
|
+
body: payload,
|
|
542
|
+
};
|
|
543
|
+
if (timestampSeconds !== undefined) input.timestamp = timestampSeconds;
|
|
544
|
+
return _b().webhook.sign(input);
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
BACKOFF_SCHEDULE_S: BACKOFF_SCHEDULE_S,
|
|
548
|
+
MAX_ATTEMPTS: MAX_ATTEMPTS,
|
|
549
|
+
|
|
275
550
|
send: async function (eventType, payload) {
|
|
276
551
|
if (typeof eventType !== "string" || eventType.length === 0) {
|
|
277
552
|
throw new TypeError("webhooks.send: eventType must be a non-empty string");
|
|
@@ -300,6 +575,8 @@ function create(opts) {
|
|
|
300
575
|
}
|
|
301
576
|
|
|
302
577
|
module.exports = {
|
|
303
|
-
create:
|
|
304
|
-
KNOWN_EVENTS:
|
|
578
|
+
create: create,
|
|
579
|
+
KNOWN_EVENTS: KNOWN_EVENTS,
|
|
580
|
+
BACKOFF_SCHEDULE_S: BACKOFF_SCHEDULE_S,
|
|
581
|
+
MAX_ATTEMPTS: MAX_ATTEMPTS,
|
|
305
582
|
};
|
package/lib/wishlist.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wishlist — customers save products (or specific variants) for
|
|
3
|
+
* later. The storefront PDP renders an "Add to wishlist" toggle that
|
|
4
|
+
* resolves through `isWishlisted` + `add` / `remove`; the account
|
|
5
|
+
* page renders the customer's saved items via `listForCustomer`; and
|
|
6
|
+
* the "X people saved this" social-proof counter on the PDP reads
|
|
7
|
+
* `countForProduct`. The home page / category page surfaces a
|
|
8
|
+
* "Most-wishlisted" rail via `popularProducts`.
|
|
9
|
+
*
|
|
10
|
+
* Composes:
|
|
11
|
+
* - `b.guardUuid` — every customer / product / variant id is
|
|
12
|
+
* UUID-shape-validated at the entry point. The primitive itself
|
|
13
|
+
* never trusts an opaque string; the guard refuses on shape and
|
|
14
|
+
* surfaces a TypeError that the storefront translates to a 400.
|
|
15
|
+
* - `b.crypto.namespaceHash` — not used for row identity (the row
|
|
16
|
+
* id is a UUIDv7 from `b.uuid.v7`); reserved for forthcoming
|
|
17
|
+
* follower / shared-wishlist features that need a deterministic
|
|
18
|
+
* handle without leaking customer ids.
|
|
19
|
+
* - `b.uuid.v7` — row id.
|
|
20
|
+
* - `b.pagination.encodeCursor` / `decodeCursor` — HMAC-tagged
|
|
21
|
+
* opaque cursor on the `(created_at DESC, id DESC)` tuple so an
|
|
22
|
+
* operator can't hand-craft a cursor to skip past a hidden row
|
|
23
|
+
* or replay one across deployments.
|
|
24
|
+
*
|
|
25
|
+
* Surface:
|
|
26
|
+
* - `add({ customer_id, product_id, variant_id?, notes? })` —
|
|
27
|
+
* INSERT OR IGNORE on the unique (customer, product, variant)
|
|
28
|
+
* tuple; returns `{ id, status: "added" | "dedup" }`.
|
|
29
|
+
* - `remove({ customer_id, product_id, variant_id? })` — DELETE
|
|
30
|
+
* the matching row; returns `{ removed: boolean }`.
|
|
31
|
+
* - `listForCustomer(customer_id, { limit?, cursor? })` —
|
|
32
|
+
* paginated entries scoped to one customer; returns
|
|
33
|
+
* `{ rows, nextCursor }`.
|
|
34
|
+
* - `isWishlisted({ customer_id, product_id, variant_id? })` —
|
|
35
|
+
* boolean lookup used by the PDP toggle.
|
|
36
|
+
* - `countForProduct(product_id)` — how many customers wishlisted
|
|
37
|
+
* this product (variant-collapsed — counts distinct customers).
|
|
38
|
+
* - `popularProducts({ limit? })` — `[ { product_id, count } ]`
|
|
39
|
+
* sorted by count desc, used for the "Most-wishlisted" rail.
|
|
40
|
+
*
|
|
41
|
+
* Storage:
|
|
42
|
+
* - `wishlist_entries` (migration `0012_wishlist.sql`).
|
|
43
|
+
*
|
|
44
|
+
* @primitive wishlist
|
|
45
|
+
* @related b.guardUuid, b.pagination, b.uuid
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
"use strict";
|
|
49
|
+
|
|
50
|
+
var MAX_NOTES_LEN = 280;
|
|
51
|
+
var DEFAULT_LIMIT = 20;
|
|
52
|
+
var MAX_LIMIT = 100;
|
|
53
|
+
var POPULAR_LIMIT = 50;
|
|
54
|
+
var WISHLIST_ORDER_KEY = ["created_at:desc", "id:desc"];
|
|
55
|
+
|
|
56
|
+
// Lazy framework handle — matches the pattern used by the rest of
|
|
57
|
+
// the shop primitives; avoids the require cycle that would arise
|
|
58
|
+
// from importing `./index` at module-eval time.
|
|
59
|
+
var bShop;
|
|
60
|
+
function _b() {
|
|
61
|
+
if (!bShop) bShop = require("./index");
|
|
62
|
+
return bShop.framework;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _uuid(s, label) {
|
|
66
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
67
|
+
catch (e) { throw new TypeError("wishlist: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _optUuid(s, label) {
|
|
71
|
+
if (s == null || s === "") return null;
|
|
72
|
+
return _uuid(s, label);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _normalizeNotes(s) {
|
|
76
|
+
if (s == null) return "";
|
|
77
|
+
if (typeof s !== "string") {
|
|
78
|
+
throw new TypeError("wishlist: notes must be a string");
|
|
79
|
+
}
|
|
80
|
+
// Refuse control bytes (incl. CR/LF) so a malicious note can't
|
|
81
|
+
// smuggle header-injection-class content into operator dashboards
|
|
82
|
+
// or downstream email templates that render the note inline.
|
|
83
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
84
|
+
throw new TypeError("wishlist: notes must not contain control bytes");
|
|
85
|
+
}
|
|
86
|
+
if (s.length > MAX_NOTES_LEN) {
|
|
87
|
+
throw new TypeError("wishlist: notes must be <= " + MAX_NOTES_LEN + " chars");
|
|
88
|
+
}
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _limit(n, label) {
|
|
93
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
94
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
95
|
+
throw new TypeError("wishlist: " + label + " must be 1..." + MAX_LIMIT);
|
|
96
|
+
}
|
|
97
|
+
return n;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function create(opts) {
|
|
101
|
+
opts = opts || {};
|
|
102
|
+
var query = opts.query;
|
|
103
|
+
if (!query) {
|
|
104
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
105
|
+
}
|
|
106
|
+
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
107
|
+
// b.pagination so an operator can't hand-craft one to skip past a
|
|
108
|
+
// hidden entry or replay across deployments. The secret defaults
|
|
109
|
+
// to a dev-only placeholder so the primitive boots in tests; the
|
|
110
|
+
// deployment is expected to supply a derived value (typically
|
|
111
|
+
// b.crypto.namespaceHash("wishlist-cursor", D1_BRIDGE_SECRET)).
|
|
112
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
113
|
+
if (process.env.NODE_ENV === "production") {
|
|
114
|
+
throw new Error("wishlist.create: opts.cursorSecret is required in production");
|
|
115
|
+
}
|
|
116
|
+
opts.cursorSecret = "wishlist-cursor-secret-dev-only";
|
|
117
|
+
}
|
|
118
|
+
var cursorSecret = opts.cursorSecret;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
MAX_NOTES_LEN: MAX_NOTES_LEN,
|
|
122
|
+
|
|
123
|
+
add: async function (input) {
|
|
124
|
+
if (!input || typeof input !== "object") {
|
|
125
|
+
throw new TypeError("wishlist.add: input object required");
|
|
126
|
+
}
|
|
127
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
128
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
129
|
+
var variantId = _optUuid(input.variant_id, "variant_id");
|
|
130
|
+
var notes = _normalizeNotes(input.notes);
|
|
131
|
+
var now = Date.now();
|
|
132
|
+
var id = _b().uuid.v7();
|
|
133
|
+
|
|
134
|
+
// Idempotent insert — re-running with the same
|
|
135
|
+
// (customer, product, variant) tuple is a no-op on the storage
|
|
136
|
+
// side. The SELECT after the INSERT-OR-IGNORE tells us which
|
|
137
|
+
// one happened so the caller can distinguish "this just got
|
|
138
|
+
// added" from "we already had this entry" for UI copy.
|
|
139
|
+
await query(
|
|
140
|
+
"INSERT OR IGNORE INTO wishlist_entries " +
|
|
141
|
+
"(id, customer_id, product_id, variant_id, notes, created_at) " +
|
|
142
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
143
|
+
[id, customerId, productId, variantId, notes, now],
|
|
144
|
+
);
|
|
145
|
+
var existing = (await query(
|
|
146
|
+
"SELECT id FROM wishlist_entries " +
|
|
147
|
+
"WHERE customer_id = ?1 AND product_id = ?2 " +
|
|
148
|
+
"AND COALESCE(variant_id, '') = COALESCE(?3, '') LIMIT 1",
|
|
149
|
+
[customerId, productId, variantId],
|
|
150
|
+
)).rows[0];
|
|
151
|
+
var status = existing && existing.id === id ? "added" : "dedup";
|
|
152
|
+
return {
|
|
153
|
+
id: existing ? existing.id : id,
|
|
154
|
+
status: status,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
remove: async function (input) {
|
|
159
|
+
if (!input || typeof input !== "object") {
|
|
160
|
+
throw new TypeError("wishlist.remove: input object required");
|
|
161
|
+
}
|
|
162
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
163
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
164
|
+
var variantId = _optUuid(input.variant_id, "variant_id");
|
|
165
|
+
var r = await query(
|
|
166
|
+
"DELETE FROM wishlist_entries " +
|
|
167
|
+
"WHERE customer_id = ?1 AND product_id = ?2 " +
|
|
168
|
+
"AND COALESCE(variant_id, '') = COALESCE(?3, '')",
|
|
169
|
+
[customerId, productId, variantId],
|
|
170
|
+
);
|
|
171
|
+
return { removed: Number(r.rowCount || 0) > 0 };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
listForCustomer: async function (customerId, listOpts) {
|
|
175
|
+
var cid = _uuid(customerId, "customer_id");
|
|
176
|
+
listOpts = listOpts || {};
|
|
177
|
+
var limit = _limit(listOpts.limit, "limit");
|
|
178
|
+
var cursorVals = null;
|
|
179
|
+
if (listOpts.cursor != null) {
|
|
180
|
+
if (typeof listOpts.cursor !== "string") {
|
|
181
|
+
throw new TypeError("wishlist.listForCustomer: cursor must be an opaque string or null");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
185
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(WISHLIST_ORDER_KEY)) {
|
|
186
|
+
throw new TypeError("wishlist.listForCustomer: cursor orderKey mismatch");
|
|
187
|
+
}
|
|
188
|
+
cursorVals = state.vals;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (e instanceof TypeError) throw e;
|
|
191
|
+
throw new TypeError("wishlist.listForCustomer: cursor — " + (e && e.message || "malformed"));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
var sql, params;
|
|
195
|
+
if (cursorVals) {
|
|
196
|
+
sql = "SELECT * FROM wishlist_entries WHERE customer_id = ?1 AND " +
|
|
197
|
+
"(created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
198
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4";
|
|
199
|
+
params = [cid, cursorVals[0], cursorVals[1], limit];
|
|
200
|
+
} else {
|
|
201
|
+
sql = "SELECT * FROM wishlist_entries WHERE customer_id = ?1 " +
|
|
202
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
203
|
+
params = [cid, limit];
|
|
204
|
+
}
|
|
205
|
+
var rows = (await query(sql, params)).rows;
|
|
206
|
+
var last = rows[rows.length - 1];
|
|
207
|
+
var nextCursor = null;
|
|
208
|
+
if (last && rows.length === limit) {
|
|
209
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
210
|
+
orderKey: WISHLIST_ORDER_KEY,
|
|
211
|
+
vals: [last.created_at, last.id],
|
|
212
|
+
forward: true,
|
|
213
|
+
}, cursorSecret);
|
|
214
|
+
}
|
|
215
|
+
return { rows: rows, nextCursor: nextCursor };
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
isWishlisted: async function (input) {
|
|
219
|
+
if (!input || typeof input !== "object") {
|
|
220
|
+
throw new TypeError("wishlist.isWishlisted: input object required");
|
|
221
|
+
}
|
|
222
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
223
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
224
|
+
var variantId = _optUuid(input.variant_id, "variant_id");
|
|
225
|
+
var r = await query(
|
|
226
|
+
"SELECT id FROM wishlist_entries " +
|
|
227
|
+
"WHERE customer_id = ?1 AND product_id = ?2 " +
|
|
228
|
+
"AND COALESCE(variant_id, '') = COALESCE(?3, '') LIMIT 1",
|
|
229
|
+
[customerId, productId, variantId],
|
|
230
|
+
);
|
|
231
|
+
return r.rows.length > 0;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
countForProduct: async function (productId) {
|
|
235
|
+
var pid = _uuid(productId, "product_id");
|
|
236
|
+
var r = await query(
|
|
237
|
+
"SELECT COUNT(DISTINCT customer_id) AS n FROM wishlist_entries WHERE product_id = ?1",
|
|
238
|
+
[pid],
|
|
239
|
+
);
|
|
240
|
+
return Number((r.rows[0] || {}).n || 0);
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
popularProducts: async function (popOpts) {
|
|
244
|
+
popOpts = popOpts || {};
|
|
245
|
+
var limit = popOpts.limit == null ? POPULAR_LIMIT
|
|
246
|
+
: _limit(popOpts.limit, "limit");
|
|
247
|
+
var rows = (await query(
|
|
248
|
+
"SELECT product_id, COUNT(DISTINCT customer_id) AS count " +
|
|
249
|
+
"FROM wishlist_entries " +
|
|
250
|
+
"GROUP BY product_id " +
|
|
251
|
+
"ORDER BY count DESC, product_id ASC " +
|
|
252
|
+
"LIMIT ?1",
|
|
253
|
+
[limit],
|
|
254
|
+
)).rows;
|
|
255
|
+
var out = [];
|
|
256
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
257
|
+
out.push({
|
|
258
|
+
product_id: rows[i].product_id,
|
|
259
|
+
count: Number(rows[i].count),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
create: create,
|
|
269
|
+
};
|
package/package.json
CHANGED