@blamejs/blamejs-shop 0.0.53 → 0.0.54
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 +2 -0
- package/lib/analytics.js +400 -0
- package/lib/email.js +264 -0
- package/lib/index.js +3 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/newsletter.js +176 -12
- package/lib/payment.js +193 -13
- package/lib/reviews.js +412 -0
- package/lib/tax.js +391 -3
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
package/lib/newsletter.js
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* from the namespace `"newsletter-email"` + the normalised
|
|
10
10
|
* address (lowercased + trimmed) so two signups for the same
|
|
11
11
|
* mailbox collapse to a single row regardless of casing
|
|
12
|
-
* variation.
|
|
12
|
+
* variation. Also keys the unsubscribe-token table off
|
|
13
|
+
* `"newsletter-unsubscribe"` + the plaintext bearer.
|
|
14
|
+
* - `b.crypto.generateBytes` + `b.crypto.toBase64Url` — opaque
|
|
15
|
+
* unsubscribe token generation (24 bytes → 32 base64url chars).
|
|
16
|
+
* - `b.crypto.timingSafeEqual` — constant-time comparison of the
|
|
17
|
+
* supplied token hash against the stored row's hash on consume.
|
|
13
18
|
* - `b.uuid.v7` — row id.
|
|
14
19
|
*
|
|
15
20
|
* Surface:
|
|
@@ -18,19 +23,37 @@
|
|
|
18
23
|
* - `byEmailHash(hash)` — operator lookup.
|
|
19
24
|
* - `count()` — total non-unsubscribed signups (for the live
|
|
20
25
|
* newsletter-band stat the home page renders).
|
|
26
|
+
* - `issueUnsubscribeToken(signup_id)` — mints a one-shot bearer
|
|
27
|
+
* for the unsubscribe URL. The plaintext token is returned
|
|
28
|
+
* once; the database stores only the namespaceHash. Default
|
|
29
|
+
* expiry is one year from issuance.
|
|
30
|
+
* - `consumeUnsubscribeToken(plaintext)` — single-use exchange.
|
|
31
|
+
* Marks the token consumed and stamps
|
|
32
|
+
* `newsletter_signups.unsubscribed_at` for the linked signup.
|
|
33
|
+
* Returns a structured result with one of the error codes
|
|
34
|
+
* `"not-found" | "already-consumed" | "expired"`, or `"ok"`
|
|
35
|
+
* on success.
|
|
36
|
+
* - `resubscribe({ email })` — clears `unsubscribed_at` for an
|
|
37
|
+
* existing signup looked up by email hash.
|
|
21
38
|
*
|
|
22
39
|
* Storage:
|
|
23
40
|
* - `newsletter_signups` (migration `0010_newsletter_signups.sql`).
|
|
41
|
+
* - `newsletter_unsubscribe_tokens`
|
|
42
|
+
* (migration `0014_newsletter_unsubscribe_tokens.sql`).
|
|
24
43
|
*
|
|
25
44
|
* @primitive newsletter
|
|
26
|
-
* @related b.guardEmail, b.crypto.namespaceHash
|
|
45
|
+
* @related b.guardEmail, b.crypto.namespaceHash,
|
|
46
|
+
* b.crypto.timingSafeEqual
|
|
27
47
|
*/
|
|
28
48
|
|
|
29
49
|
"use strict";
|
|
30
50
|
|
|
31
|
-
var EMAIL_NAMESPACE
|
|
32
|
-
var
|
|
33
|
-
var
|
|
51
|
+
var EMAIL_NAMESPACE = "newsletter-email";
|
|
52
|
+
var UNSUBSCRIBE_NAMESPACE = "newsletter-unsubscribe";
|
|
53
|
+
var UNSUBSCRIBE_TOKEN_BYTES = 24;
|
|
54
|
+
var UNSUBSCRIBE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
55
|
+
var MAX_SOURCE_LEN = 64;
|
|
56
|
+
var SOURCE_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$/;
|
|
34
57
|
|
|
35
58
|
// Lazy framework handle — matches the pattern used by the rest of
|
|
36
59
|
// the shop primitives; avoids the `require` cycle that would arise
|
|
@@ -42,16 +65,37 @@ function _b() {
|
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
function _normalizeEmail(s) {
|
|
45
|
-
if (typeof s !== "string") {
|
|
46
|
-
throw new TypeError("newsletter: email must be a string");
|
|
68
|
+
if (typeof s !== "string" || !s.length) {
|
|
69
|
+
throw new TypeError("newsletter: email must be a non-empty string");
|
|
47
70
|
}
|
|
48
71
|
var trimmed = s.trim();
|
|
72
|
+
if (!trimmed.length) {
|
|
73
|
+
throw new TypeError("newsletter: email must be a non-empty string");
|
|
74
|
+
}
|
|
49
75
|
// Defer the shape check to `b.guardEmail`. The guard refuses
|
|
50
|
-
// control bytes,
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
76
|
+
// control bytes, oversized input, header-injection sequences,
|
|
77
|
+
// and RFC-shape violations. `validate` surfaces a structured
|
|
78
|
+
// `{ ok, issues }` report; we re-raise the first issue's
|
|
79
|
+
// snippet so the caller gets a precise refusal reason rather
|
|
80
|
+
// than the generic sanitize message.
|
|
81
|
+
var guardEmail = _b().guardEmail;
|
|
82
|
+
var report;
|
|
83
|
+
try {
|
|
84
|
+
report = guardEmail.validate(trimmed, { profile: "strict" });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new TypeError("newsletter: email — " + (e && e.message || "invalid email"));
|
|
87
|
+
}
|
|
88
|
+
if (!report || report.ok === false) {
|
|
89
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
90
|
+
throw new TypeError("newsletter: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
91
|
+
}
|
|
92
|
+
var canonical;
|
|
93
|
+
try {
|
|
94
|
+
canonical = guardEmail.sanitize(trimmed, { profile: "strict" });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
throw new TypeError("newsletter: email — " + (e && e.message || "refused"));
|
|
97
|
+
}
|
|
98
|
+
return canonical.toLowerCase();
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
function _normalizeSource(s) {
|
|
@@ -134,6 +178,126 @@ function create(opts) {
|
|
|
134
178
|
);
|
|
135
179
|
return Number((r.rows[0] || {}).n || 0);
|
|
136
180
|
},
|
|
181
|
+
|
|
182
|
+
// Mint a single-use opaque bearer for the unsubscribe URL. The
|
|
183
|
+
// plaintext is returned ONCE — the database stores only its
|
|
184
|
+
// namespaceHash, so re-issuance is impossible (and a leak of
|
|
185
|
+
// the storage layer never reveals the live links the operator
|
|
186
|
+
// emailed out). Caller is responsible for delivering the
|
|
187
|
+
// plaintext through the transactional-email primitive.
|
|
188
|
+
issueUnsubscribeToken: async function (signupId) {
|
|
189
|
+
if (typeof signupId !== "string" || !signupId.length) {
|
|
190
|
+
throw new TypeError("newsletter.issueUnsubscribeToken: signup_id required");
|
|
191
|
+
}
|
|
192
|
+
var row = (await query(
|
|
193
|
+
"SELECT id FROM newsletter_signups WHERE id = ?1 LIMIT 1",
|
|
194
|
+
[signupId],
|
|
195
|
+
)).rows[0];
|
|
196
|
+
if (!row) {
|
|
197
|
+
throw new TypeError("newsletter.issueUnsubscribeToken: signup_id not found");
|
|
198
|
+
}
|
|
199
|
+
var plaintext = _b().crypto.toBase64Url(
|
|
200
|
+
_b().crypto.generateBytes(UNSUBSCRIBE_TOKEN_BYTES)
|
|
201
|
+
);
|
|
202
|
+
var tokenHash = _b().crypto.namespaceHash(UNSUBSCRIBE_NAMESPACE, plaintext);
|
|
203
|
+
var now = Date.now();
|
|
204
|
+
var expiresAt = now + UNSUBSCRIBE_TTL_MS;
|
|
205
|
+
await query(
|
|
206
|
+
"INSERT INTO newsletter_unsubscribe_tokens " +
|
|
207
|
+
"(token_hash, signup_id, created_at, expires_at) " +
|
|
208
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
209
|
+
[tokenHash, signupId, now, expiresAt],
|
|
210
|
+
);
|
|
211
|
+
// The plaintext leaves this function exactly once. Callers
|
|
212
|
+
// pass it into the email body; never log it, never persist
|
|
213
|
+
// it server-side — the row above is the only durable handle.
|
|
214
|
+
return { token: plaintext, expires_at: expiresAt };
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
// Single-use redeem. Errors are structured (not thrown) because
|
|
218
|
+
// the caller is typically an HTTP handler that needs to render a
|
|
219
|
+
// friendly page for each failure mode. The lookup itself runs
|
|
220
|
+
// off a constant-shape hash → primary-key query; the
|
|
221
|
+
// `timingSafeEqual` re-comparison on hit equalises the on-CPU
|
|
222
|
+
// work between the hit and miss paths so an attacker can't
|
|
223
|
+
// distinguish "this token never existed" from "this token was
|
|
224
|
+
// already used" through the response-latency channel. The
|
|
225
|
+
// plaintext token is never logged or echoed back.
|
|
226
|
+
consumeUnsubscribeToken: async function (plaintext) {
|
|
227
|
+
if (typeof plaintext !== "string" || !plaintext.length) {
|
|
228
|
+
return { ok: false, error: "not-found" };
|
|
229
|
+
}
|
|
230
|
+
var tokenHash = _b().crypto.namespaceHash(UNSUBSCRIBE_NAMESPACE, plaintext);
|
|
231
|
+
var row = (await query(
|
|
232
|
+
"SELECT token_hash, signup_id, consumed_at, expires_at " +
|
|
233
|
+
"FROM newsletter_unsubscribe_tokens WHERE token_hash = ?1 LIMIT 1",
|
|
234
|
+
[tokenHash],
|
|
235
|
+
)).rows[0];
|
|
236
|
+
if (!row) {
|
|
237
|
+
// Miss path — burn the same comparison work as the hit
|
|
238
|
+
// path so the latency profile matches. The compared values
|
|
239
|
+
// are deliberately equal-length and equal (the hash
|
|
240
|
+
// against itself); the result is discarded.
|
|
241
|
+
_b().crypto.timingSafeEqual(tokenHash, tokenHash);
|
|
242
|
+
return { ok: false, error: "not-found" };
|
|
243
|
+
}
|
|
244
|
+
// Constant-time check of the stored hash against the
|
|
245
|
+
// recomputed hash — defensive belt-and-braces against any
|
|
246
|
+
// future change to the primary-key index implementation.
|
|
247
|
+
var matched = _b().crypto.timingSafeEqual(row.token_hash, tokenHash);
|
|
248
|
+
if (!matched) return { ok: false, error: "not-found" };
|
|
249
|
+
if (row.consumed_at != null) {
|
|
250
|
+
return { ok: false, error: "already-consumed" };
|
|
251
|
+
}
|
|
252
|
+
var now = Date.now();
|
|
253
|
+
if (Number(row.expires_at) <= now) {
|
|
254
|
+
return { ok: false, error: "expired" };
|
|
255
|
+
}
|
|
256
|
+
await query(
|
|
257
|
+
"UPDATE newsletter_unsubscribe_tokens SET consumed_at = ?1 " +
|
|
258
|
+
"WHERE token_hash = ?2 AND consumed_at IS NULL",
|
|
259
|
+
[now, tokenHash],
|
|
260
|
+
);
|
|
261
|
+
await query(
|
|
262
|
+
"UPDATE newsletter_signups SET unsubscribed_at = ?1 WHERE id = ?2",
|
|
263
|
+
[now, row.signup_id],
|
|
264
|
+
);
|
|
265
|
+
var signup = (await query(
|
|
266
|
+
"SELECT email_hash FROM newsletter_signups WHERE id = ?1 LIMIT 1",
|
|
267
|
+
[row.signup_id],
|
|
268
|
+
)).rows[0];
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
error: "ok",
|
|
272
|
+
signup_id: row.signup_id,
|
|
273
|
+
email_hash: signup ? signup.email_hash : null,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
// A subscriber who unsubscribed and changed their mind. The
|
|
278
|
+
// signup row stays the same id — we just clear the
|
|
279
|
+
// `unsubscribed_at` stamp. No new row, no new hash, so the
|
|
280
|
+
// operator's analytics keep continuity. Returns `ok: false`
|
|
281
|
+
// (without throwing) when the address has never signed up; the
|
|
282
|
+
// caller decides whether to surface "you weren't subscribed" or
|
|
283
|
+
// silently create a fresh signup via `.signup({ email })`.
|
|
284
|
+
resubscribe: async function (input) {
|
|
285
|
+
if (!input || typeof input !== "object") {
|
|
286
|
+
throw new TypeError("newsletter.resubscribe: input object required");
|
|
287
|
+
}
|
|
288
|
+
var emailNormalized = _normalizeEmail(input.email);
|
|
289
|
+
var emailHash = _b().crypto.namespaceHash(EMAIL_NAMESPACE, emailNormalized);
|
|
290
|
+
var existing = (await query(
|
|
291
|
+
"SELECT id FROM newsletter_signups WHERE email_hash = ?1 LIMIT 1",
|
|
292
|
+
[emailHash],
|
|
293
|
+
)).rows[0];
|
|
294
|
+
if (!existing) return { ok: false };
|
|
295
|
+
await query(
|
|
296
|
+
"UPDATE newsletter_signups SET unsubscribed_at = NULL WHERE id = ?1",
|
|
297
|
+
[existing.id],
|
|
298
|
+
);
|
|
299
|
+
return { ok: true, signup_id: existing.id };
|
|
300
|
+
},
|
|
137
301
|
};
|
|
138
302
|
}
|
|
139
303
|
|
package/lib/payment.js
CHANGED
|
@@ -34,6 +34,20 @@ var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
|
|
|
34
34
|
var STRIPE_HTTP_TIMEOUT_MS = 15000;
|
|
35
35
|
var CURRENCY_RE = /^[a-z]{3}$/; // Stripe wants lowercase ISO 4217
|
|
36
36
|
|
|
37
|
+
// Stripe holds idempotency keys for 24h, so the local cache row
|
|
38
|
+
// expires on the same window — operators who run `cleanupExpired()`
|
|
39
|
+
// on a daily schedule keep the table small without ever shortening
|
|
40
|
+
// the replay window below Stripe's own retention.
|
|
41
|
+
var IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
var IDEMPOTENCY_NAMESPACE = "payment-idempotency-body";
|
|
43
|
+
var IDEMPOTENT_OPERATIONS = {
|
|
44
|
+
"payment_intent.create": true,
|
|
45
|
+
"refund.create": true,
|
|
46
|
+
"subscription.create": true,
|
|
47
|
+
"subscription.update": true,
|
|
48
|
+
"subscription.cancel": true,
|
|
49
|
+
};
|
|
50
|
+
|
|
37
51
|
// ---- validation -----------------------------------------------------------
|
|
38
52
|
|
|
39
53
|
function _assertSecret(s, label) {
|
|
@@ -161,7 +175,8 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
|
|
|
161
175
|
if (idempotencyKey) {
|
|
162
176
|
headers["idempotency-key"] = idempotencyKey;
|
|
163
177
|
}
|
|
164
|
-
var
|
|
178
|
+
var httpClient = opts.httpClient || _b().httpClient;
|
|
179
|
+
var res = await httpClient.request({
|
|
165
180
|
method: method,
|
|
166
181
|
url: url,
|
|
167
182
|
headers: headers,
|
|
@@ -177,17 +192,138 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
|
|
|
177
192
|
err.code = (json && json.error && json.error.code) || "STRIPE_HTTP_" + res.statusCode;
|
|
178
193
|
err.statusCode = res.statusCode;
|
|
179
194
|
err.stripe = json && json.error || null;
|
|
195
|
+
err._stripeRawText = text;
|
|
196
|
+
err._stripeStatus = res.statusCode;
|
|
180
197
|
throw err;
|
|
181
198
|
}
|
|
199
|
+
// Carry the raw status + serialised body alongside the parsed JSON
|
|
200
|
+
// so the idempotency layer can persist them verbatim for replay
|
|
201
|
+
// without re-stringifying (preserves byte-for-byte fidelity with
|
|
202
|
+
// what Stripe returned, including field ordering).
|
|
203
|
+
Object.defineProperty(json, "_stripeStatus", { value: res.statusCode, enumerable: false });
|
|
204
|
+
Object.defineProperty(json, "_stripeRawText", { value: text, enumerable: false });
|
|
182
205
|
return json;
|
|
183
206
|
}
|
|
184
207
|
|
|
208
|
+
// ---- Idempotency ----------------------------------------------------------
|
|
209
|
+
//
|
|
210
|
+
// Canonical-JSON hash. Stable across runtime, OS, node version: sort
|
|
211
|
+
// every object key recursively, JSON.stringify the result, then run
|
|
212
|
+
// through b.crypto.namespaceHash (SHA3-512). Arrays preserve order
|
|
213
|
+
// (their order is semantically meaningful — `items[]` in a Stripe
|
|
214
|
+
// subscription is an ordered list of line items).
|
|
215
|
+
function _canonicalise(v) {
|
|
216
|
+
if (v === null || typeof v !== "object") return v;
|
|
217
|
+
if (Array.isArray(v)) {
|
|
218
|
+
var out = [];
|
|
219
|
+
for (var i = 0; i < v.length; i += 1) out.push(_canonicalise(v[i]));
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
var keys = Object.keys(v).sort();
|
|
223
|
+
var obj = {};
|
|
224
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
225
|
+
var val = v[keys[k]];
|
|
226
|
+
if (val === undefined) continue;
|
|
227
|
+
obj[keys[k]] = _canonicalise(val);
|
|
228
|
+
}
|
|
229
|
+
return obj;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _canonicalHash(obj) {
|
|
233
|
+
var canonical = JSON.stringify(_canonicalise(obj == null ? {} : obj));
|
|
234
|
+
return _b().crypto.namespaceHash(IDEMPOTENCY_NAMESPACE, canonical);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _assertIdempotencyKey(k) {
|
|
238
|
+
if (typeof k !== "string" || k.length < 8 || k.length > 255) {
|
|
239
|
+
throw new TypeError("payment: idempotency_key must be a string between 8 and 255 characters");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Wraps a single Stripe mutating call in the idempotency cache.
|
|
244
|
+
//
|
|
245
|
+
// 1. Look up (idempotency_key). If present + same request_hash →
|
|
246
|
+
// replay the stored response verbatim. If present + DIFFERENT
|
|
247
|
+
// request_hash → throw (security: never let a same-key replay
|
|
248
|
+
// with a mutated body pass through).
|
|
249
|
+
// 2. Otherwise, call Stripe via `doCall()`. On 2xx, INSERT the
|
|
250
|
+
// response row. On any throw, leave the cache empty — the next
|
|
251
|
+
// call with the same key retries cleanly.
|
|
252
|
+
async function _runIdempotent(state, operation, key, requestObj, doCall) {
|
|
253
|
+
_assertIdempotencyKey(key);
|
|
254
|
+
if (!IDEMPOTENT_OPERATIONS[operation]) {
|
|
255
|
+
throw new TypeError("payment: unknown idempotent operation " + JSON.stringify(operation));
|
|
256
|
+
}
|
|
257
|
+
var query = state.query;
|
|
258
|
+
var now = state.now();
|
|
259
|
+
var requestHash = _canonicalHash(requestObj);
|
|
260
|
+
|
|
261
|
+
// Replay lookup. The PRIMARY KEY index makes this an O(1) probe.
|
|
262
|
+
var existing = (await query(
|
|
263
|
+
"SELECT request_hash, response_status, response_body " +
|
|
264
|
+
"FROM payment_idempotency WHERE idempotency_key = ?1 LIMIT 1",
|
|
265
|
+
[key],
|
|
266
|
+
)).rows[0];
|
|
267
|
+
|
|
268
|
+
if (existing) {
|
|
269
|
+
if (existing.request_hash !== requestHash) {
|
|
270
|
+
// Same key, different body — refuse. Stripe itself would reject
|
|
271
|
+
// this on its own idempotency cache, but we surface a typed
|
|
272
|
+
// application error so the caller doesn't have to ship the
|
|
273
|
+
// request first to discover the collision.
|
|
274
|
+
throw new TypeError("payment: idempotency_key collision (different inputs)");
|
|
275
|
+
}
|
|
276
|
+
var replay = null;
|
|
277
|
+
try { replay = JSON.parse(existing.response_body); } catch (_e) { replay = { _raw: existing.response_body }; }
|
|
278
|
+
Object.defineProperty(replay, "_stripeStatus", { value: Number(existing.response_status), enumerable: false });
|
|
279
|
+
Object.defineProperty(replay, "_replayed", { value: true, enumerable: false });
|
|
280
|
+
return replay;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
var result = await doCall();
|
|
284
|
+
var status = result && result._stripeStatus ? Number(result._stripeStatus) : 200;
|
|
285
|
+
var rawText = result && result._stripeRawText
|
|
286
|
+
? result._stripeRawText
|
|
287
|
+
: JSON.stringify(result);
|
|
288
|
+
|
|
289
|
+
await query(
|
|
290
|
+
"INSERT INTO payment_idempotency " +
|
|
291
|
+
"(idempotency_key, operation, request_hash, response_status, response_body, created_at, expires_at) " +
|
|
292
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
293
|
+
[key, operation, requestHash, status, rawText, now, now + IDEMPOTENCY_TTL_MS],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
185
299
|
// ---- adapter --------------------------------------------------------------
|
|
186
300
|
|
|
187
301
|
function stripe(opts) {
|
|
188
302
|
opts = opts || {};
|
|
189
303
|
_assertSecret(opts.apiKey, "apiKey");
|
|
190
304
|
_assertSecret(opts.webhookSecret, "webhookSecret");
|
|
305
|
+
if (opts.query != null && typeof opts.query !== "function") {
|
|
306
|
+
throw new TypeError("payment: query must be a function (sql, params) => Promise<{ rows }>");
|
|
307
|
+
}
|
|
308
|
+
if (opts.now != null && typeof opts.now !== "function") {
|
|
309
|
+
throw new TypeError("payment: now must be a function returning current epoch ms");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Idempotency state shared across every mutating call. When `query`
|
|
313
|
+
// is not supplied the primitive runs in legacy mode — every
|
|
314
|
+
// mutating call goes straight to Stripe, no cache writes, no
|
|
315
|
+
// collision detection. Operators opt in by passing `query`.
|
|
316
|
+
var state = {
|
|
317
|
+
query: opts.query || null,
|
|
318
|
+
now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
function _maybeIdempotent(operation, idempotencyKey, requestObj, doCall) {
|
|
322
|
+
if (!state.query || idempotencyKey == null) {
|
|
323
|
+
return doCall();
|
|
324
|
+
}
|
|
325
|
+
return _runIdempotent(state, operation, idempotencyKey, requestObj, doCall);
|
|
326
|
+
}
|
|
191
327
|
|
|
192
328
|
return {
|
|
193
329
|
name: "stripe",
|
|
@@ -211,7 +347,9 @@ function stripe(opts) {
|
|
|
211
347
|
if (input.metadata) params.metadata = input.metadata;
|
|
212
348
|
if (input.description) params.description = input.description;
|
|
213
349
|
if (input.receipt_email) params.receipt_email = input.receipt_email;
|
|
214
|
-
return
|
|
350
|
+
return _maybeIdempotent("payment_intent.create", idempotencyKey, params, function () {
|
|
351
|
+
return _stripeCall(opts, "POST", "/payment_intents", params, idempotencyKey);
|
|
352
|
+
});
|
|
215
353
|
},
|
|
216
354
|
|
|
217
355
|
retrievePaymentIntent: function (id) {
|
|
@@ -246,7 +384,9 @@ function stripe(opts) {
|
|
|
246
384
|
params.reason = input.reason;
|
|
247
385
|
}
|
|
248
386
|
if (input.metadata) params.metadata = input.metadata;
|
|
249
|
-
return
|
|
387
|
+
return _maybeIdempotent("refund.create", idempotencyKey, params, function () {
|
|
388
|
+
return _stripeCall(opts, "POST", "/refunds", params, idempotencyKey);
|
|
389
|
+
});
|
|
250
390
|
},
|
|
251
391
|
|
|
252
392
|
// Stripe Subscriptions API. Operators pre-create the recurring
|
|
@@ -270,7 +410,9 @@ function stripe(opts) {
|
|
|
270
410
|
if (input.metadata) params.metadata = input.metadata;
|
|
271
411
|
if (input.payment_behavior) params.payment_behavior = input.payment_behavior;
|
|
272
412
|
if (input.expand) params.expand = input.expand;
|
|
273
|
-
return
|
|
413
|
+
return _maybeIdempotent("subscription.create", idempotencyKey, params, function () {
|
|
414
|
+
return _stripeCall(opts, "POST", "/subscriptions", params, idempotencyKey);
|
|
415
|
+
});
|
|
274
416
|
},
|
|
275
417
|
|
|
276
418
|
retrieve: function (id) {
|
|
@@ -281,22 +423,58 @@ function stripe(opts) {
|
|
|
281
423
|
update: function (id, input, idempotencyKey) {
|
|
282
424
|
_assertSecret(id, "subscription id");
|
|
283
425
|
if (!input || typeof input !== "object") throw new TypeError("payment.subscriptions.update: input object required");
|
|
284
|
-
|
|
426
|
+
// The hashed request body includes the subscription id so an
|
|
427
|
+
// update against a DIFFERENT subscription with the same key
|
|
428
|
+
// is detected as a collision (the id is part of the URL,
|
|
429
|
+
// not the body Stripe sees, but it's part of the semantic
|
|
430
|
+
// request — replaying against a different sub_ would be the
|
|
431
|
+
// same security hole as replaying with a different amount).
|
|
432
|
+
var hashBody = { _id: id, body: input };
|
|
433
|
+
return _maybeIdempotent("subscription.update", idempotencyKey, hashBody, function () {
|
|
434
|
+
return _stripeCall(opts, "POST", "/subscriptions/" + encodeURIComponent(id), input, idempotencyKey);
|
|
435
|
+
});
|
|
285
436
|
},
|
|
286
437
|
|
|
287
438
|
cancel: function (id, opts2, idempotencyKey) {
|
|
288
439
|
_assertSecret(id, "subscription id");
|
|
289
440
|
opts2 = opts2 || {};
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
441
|
+
var atPeriodEnd = !!opts2.at_period_end;
|
|
442
|
+
var hashBody = { _id: id, at_period_end: atPeriodEnd };
|
|
443
|
+
return _maybeIdempotent("subscription.cancel", idempotencyKey, hashBody, function () {
|
|
444
|
+
if (atPeriodEnd) {
|
|
445
|
+
// Stripe modeled "cancel at period end" as an UPDATE so the
|
|
446
|
+
// subscription stays active through the current billing
|
|
447
|
+
// window; DELETE is for immediate end-of-life.
|
|
448
|
+
return _stripeCall(opts, "POST", "/subscriptions/" + encodeURIComponent(id),
|
|
449
|
+
{ cancel_at_period_end: true }, idempotencyKey);
|
|
450
|
+
}
|
|
451
|
+
return _stripeCall(opts, "DELETE", "/subscriptions/" + encodeURIComponent(id), null, idempotencyKey);
|
|
452
|
+
});
|
|
298
453
|
},
|
|
299
454
|
},
|
|
455
|
+
|
|
456
|
+
// Purges every expired idempotency row. Operators wire this into
|
|
457
|
+
// a daily schedule (cron, scheduled Worker, etc.) — the table
|
|
458
|
+
// grows by at most one row per mutating call per 24h window and
|
|
459
|
+
// a daily sweep keeps the high-water mark bounded. Returns the
|
|
460
|
+
// number of rows removed so the operator can alert on a sudden
|
|
461
|
+
// spike.
|
|
462
|
+
cleanupExpired: async function () {
|
|
463
|
+
if (!state.query) {
|
|
464
|
+
throw new TypeError("payment.cleanupExpired: requires `query` factory opt — idempotency cache is opt-in");
|
|
465
|
+
}
|
|
466
|
+
var cutoff = state.now();
|
|
467
|
+
var r = await state.query(
|
|
468
|
+
"DELETE FROM payment_idempotency WHERE expires_at < ?1",
|
|
469
|
+
[cutoff],
|
|
470
|
+
);
|
|
471
|
+
// D1's DELETE result shape exposes `meta.changes`; fall back to
|
|
472
|
+
// `rowsAffected` for adapters that surface it differently.
|
|
473
|
+
if (r && r.meta && typeof r.meta.changes === "number") return r.meta.changes;
|
|
474
|
+
if (r && typeof r.rowsAffected === "number") return r.rowsAffected;
|
|
475
|
+
if (r && typeof r.changes === "number") return r.changes;
|
|
476
|
+
return 0;
|
|
477
|
+
},
|
|
300
478
|
};
|
|
301
479
|
}
|
|
302
480
|
|
|
@@ -312,7 +490,9 @@ module.exports = {
|
|
|
312
490
|
create: create,
|
|
313
491
|
stripe: stripe,
|
|
314
492
|
STRIPE_WEBHOOK_TOLERANCE: STRIPE_WEBHOOK_TOLERANCE,
|
|
493
|
+
IDEMPOTENCY_TTL_MS: IDEMPOTENCY_TTL_MS,
|
|
315
494
|
// Exposed for tests + Worker to share form-encoding shape.
|
|
316
495
|
_formEncode: _formEncode,
|
|
317
496
|
_verifyWebhook: _verifyWebhook,
|
|
497
|
+
_canonicalHash: _canonicalHash,
|
|
318
498
|
};
|