@blamejs/blamejs-shop 0.0.52 → 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.
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.giftcards
4
+ * @title Gift cards primitive — issue and redeem prepaid balance
5
+ *
6
+ * @intro
7
+ * A gift card is a bearer credential. Whoever knows the plaintext
8
+ * code can spend the balance — so the shop never stores it. On
9
+ * `issue` we generate a 16-character alphanumeric code from
10
+ * `b.crypto.generateBytes`, store the
11
+ * `b.crypto.namespaceHash("giftcard-code", plaintext)` digest, and
12
+ * return the plaintext exactly once. The issuer is responsible for
13
+ * delivering it to the recipient (email, paper insert, etc.).
14
+ *
15
+ * The `code_hint` is the last 4 plaintext characters — useful when
16
+ * an operator triaging a support request needs to identify which
17
+ * row the customer is asking about. Four characters of a 32-letter
18
+ * alphabet is 2^20 ≈ one-in-a-million space, far too small to
19
+ * enable brute-force recovery of the remaining 12.
20
+ *
21
+ * Recipients without an account are addressed by an email-hash
22
+ * (`b.crypto.namespaceHash("giftcard-recipient", email)`) so a
23
+ * stolen D1 dump leaks no recipient addresses while still letting
24
+ * the storefront resolve "this address owns these cards" after the
25
+ * recipient registers.
26
+ *
27
+ * Composition:
28
+ * var gc = bShop.giftcards.create({ query: q });
29
+ * var { id, code, code_hint } = await gc.issue({
30
+ * amount_minor: 5000, currency: "USD", issued_to_email: "alice@example.com",
31
+ * });
32
+ * // deliver `code` to the recipient. Never readable again.
33
+ * var view = await gc.balance(code);
34
+ * var { remaining_balance_minor, redemption_id } =
35
+ * await gc.redeem({ code: code, order_id: orderId, amount_minor: 2500 });
36
+ *
37
+ * Display formatting (`XXXX-XXXX-XXXX-XXXX`) is purely cosmetic —
38
+ * redemption + balance + lookup all strip hyphens (and ASCII
39
+ * whitespace) before hashing, so a customer who types the dashes
40
+ * back in works without special handling.
41
+ */
42
+
43
+ var bShop;
44
+ function _b() {
45
+ if (!bShop) bShop = require("./index");
46
+ return bShop.framework;
47
+ }
48
+
49
+ var CODE_NAMESPACE = "giftcard-code";
50
+ var RECIPIENT_NAMESPACE = "giftcard-recipient";
51
+
52
+ // Alphabet excludes 0/O/I/1 so a code spoken aloud / read off a
53
+ // printed insert doesn't collapse into ambiguous characters. 32
54
+ // glyphs means each byte modulo-32 lands on a uniform draw (256 is a
55
+ // multiple of 32 — no modulo-bias correction needed).
56
+ var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
57
+ var CODE_LEN = 16;
58
+ var CODE_HINT_LEN = 4;
59
+ var CODE_ALPHABET_RE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$/;
60
+
61
+ var CURRENCY_RE = /^[A-Z]{3}$/;
62
+ var STATUSES = ["active", "redeemed", "expired", "voided"];
63
+
64
+ // ---- validators ---------------------------------------------------------
65
+
66
+ function _uuid(s, label) {
67
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
68
+ catch (e) { throw new TypeError("giftcards: " + label + " — " + (e && e.message || "invalid UUID")); }
69
+ }
70
+
71
+ function _amountMinor(n, label) {
72
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
73
+ throw new TypeError("giftcards: " + label + " must be a positive integer (minor units)");
74
+ }
75
+ return n;
76
+ }
77
+
78
+ function _currency(c) {
79
+ // ISO 4217 alpha-3, uppercase. Matches the storage CHECK
80
+ // (length(currency) = 3) and the operator-facing convention.
81
+ if (typeof c !== "string" || !CURRENCY_RE.test(c)) {
82
+ throw new TypeError("giftcards: currency must be 3-letter uppercase ISO 4217");
83
+ }
84
+ return c;
85
+ }
86
+
87
+ function _status(s) {
88
+ if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
89
+ throw new TypeError("giftcards: status must be one of " + STATUSES.join(", "));
90
+ }
91
+ return s;
92
+ }
93
+
94
+ function _expiresAt(ts) {
95
+ if (ts == null) return null;
96
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts <= 0) {
97
+ throw new TypeError("giftcards: expires_at must be a positive integer epoch-ms or null");
98
+ }
99
+ return ts;
100
+ }
101
+
102
+ function _now() { return Date.now(); }
103
+
104
+ // ---- code generation + canonicalization ---------------------------------
105
+
106
+ // Map random bytes to the 32-character alphabet. 256 % 32 === 0 so
107
+ // each modulo lands on a uniform alphabet index — no rejection
108
+ // sampling needed. Routes through `b.crypto.generateBytes` (SHAKE256
109
+ // over OS-RNG) for defense-in-depth over the bare OS RNG.
110
+ function _generateCode() {
111
+ var buf = _b().crypto.generateBytes(CODE_LEN);
112
+ var out = "";
113
+ for (var i = 0; i < CODE_LEN; i += 1) {
114
+ out += CODE_ALPHABET.charAt(buf[i] & 31);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // Display form: XXXX-XXXX-XXXX-XXXX. Pure cosmetic — the hash
120
+ // derivation runs on the canonicalized (hyphen-stripped) form.
121
+ function _formatCode(plain) {
122
+ return plain.slice(0, 4) + "-" + plain.slice(4, 8) + "-" + plain.slice(8, 12) + "-" + plain.slice(12, 16);
123
+ }
124
+
125
+ // Strip hyphens + ASCII whitespace so a customer who types the
126
+ // dashes (or pastes with a trailing newline) works without special
127
+ // handling. Returns the canonical 16-character uppercase code, or
128
+ // throws if the result isn't a well-formed alphabet draw.
129
+ function _canonicalCode(input) {
130
+ if (typeof input !== "string" || !input.length) {
131
+ throw new TypeError("giftcards: code must be a non-empty string");
132
+ }
133
+ // Operator-facing affordance: tolerate hyphens + ASCII whitespace
134
+ // anywhere. Anything else (including unicode whitespace, control
135
+ // bytes, or out-of-alphabet glyphs) is a refusal — we don't want a
136
+ // sloppy normalizer to map two distinct codes to the same hash.
137
+ var stripped = input.replace(/[-\s]+/g, "").toUpperCase();
138
+ if (stripped.length !== CODE_LEN) {
139
+ throw new TypeError("giftcards: code must be " + CODE_LEN + " alphabet characters (hyphens optional)");
140
+ }
141
+ if (!CODE_ALPHABET_RE.test(stripped)) {
142
+ throw new TypeError("giftcards: code contains characters outside the gift-card alphabet");
143
+ }
144
+ return stripped;
145
+ }
146
+
147
+ function _hashCode(canonical) {
148
+ return _b().crypto.namespaceHash(CODE_NAMESPACE, canonical);
149
+ }
150
+
151
+ function _hashRecipient(email) {
152
+ // Recipient email is a free-form operator-supplied string at this
153
+ // tier; the storefront route layer is responsible for guardEmail
154
+ // validation before issuing the card. Here we only enforce
155
+ // shape-tier: non-empty, no control bytes (the namespaceHash
156
+ // primitive refuses CR/LF itself).
157
+ if (typeof email !== "string" || !email.length) {
158
+ throw new TypeError("giftcards: issued_to_email must be a non-empty string when provided");
159
+ }
160
+ // Lowercase the address before hashing so two casings of the same
161
+ // recipient collide on lookup. Local-part case sensitivity (RFC
162
+ // 5321) is operator-irrelevant for gift-card delivery — operators
163
+ // address the human, not the mailbox.
164
+ return _b().crypto.namespaceHash(RECIPIENT_NAMESPACE, email.toLowerCase());
165
+ }
166
+
167
+ // ---- factory ------------------------------------------------------------
168
+
169
+ function create(opts) {
170
+ opts = opts || {};
171
+ var query = opts.query;
172
+ if (!query) {
173
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
174
+ }
175
+
176
+ // `lookup` resolves a plaintext code to the live card row. Returns
177
+ // null on no match. The hash compare itself is constant-time
178
+ // because we go through `b.crypto.namespaceHash` (SHA3-512
179
+ // deterministic) and then SQL = comparison on the hex hash — an
180
+ // attacker who can time the query can't distinguish "no row" from
181
+ // "wrong hash" because both paths execute the same query.
182
+ // We additionally route the hex compare through
183
+ // `b.crypto.timingSafeEqual` for the returned row so a future
184
+ // refactor that adds non-constant-time matching can't slip in.
185
+ async function _lookup(plaintextCode) {
186
+ var canonical = _canonicalCode(plaintextCode);
187
+ var hash = _hashCode(canonical);
188
+ var r = await query(
189
+ "SELECT id, code_hash, balance_minor, currency, status, expires_at " +
190
+ "FROM giftcards WHERE code_hash = ?1",
191
+ [hash],
192
+ );
193
+ if (!r.rows.length) return null;
194
+ var row = r.rows[0];
195
+ // Belt-and-braces: the SQL = already matched, but route the hex
196
+ // strings through timingSafeEqual so the equality check leaves no
197
+ // micro-timing oracle in case a future schema change moves to a
198
+ // collection scan.
199
+ if (!_b().crypto.timingSafeEqual(row.code_hash, hash)) return null;
200
+ return {
201
+ id: row.id,
202
+ balance_minor: row.balance_minor,
203
+ currency: row.currency,
204
+ status: row.status,
205
+ expires_at: row.expires_at,
206
+ };
207
+ }
208
+
209
+ return {
210
+ CODE_NAMESPACE: CODE_NAMESPACE,
211
+ RECIPIENT_NAMESPACE: RECIPIENT_NAMESPACE,
212
+ CODE_ALPHABET: CODE_ALPHABET,
213
+ CODE_LEN: CODE_LEN,
214
+ STATUSES: STATUSES,
215
+
216
+ issue: async function (input) {
217
+ if (!input || typeof input !== "object") {
218
+ throw new TypeError("giftcards.issue: input object required");
219
+ }
220
+ _amountMinor(input.amount_minor, "amount_minor");
221
+ _currency(input.currency);
222
+ var expiresAt = _expiresAt(input.expires_at);
223
+
224
+ var issuedToCustomerId = null;
225
+ if (input.issued_to_customer_id != null) {
226
+ issuedToCustomerId = _uuid(input.issued_to_customer_id, "issued_to_customer_id");
227
+ }
228
+ var issuedToEmailHash = null;
229
+ if (input.issued_to_email != null) {
230
+ issuedToEmailHash = _hashRecipient(input.issued_to_email);
231
+ }
232
+
233
+ // Allow neither, either, or both. A purely operator-issued card
234
+ // (promotion / refund credit) has no recipient identity at all.
235
+
236
+ var id = _b().uuid.v7();
237
+ var code = _generateCode();
238
+ var hash = _hashCode(code);
239
+ var hint = code.slice(CODE_LEN - CODE_HINT_LEN);
240
+ var ts = _now();
241
+
242
+ await query(
243
+ "INSERT INTO giftcards (id, code_hash, code_hint, currency, issued_minor, balance_minor, " +
244
+ "issued_to_customer_id, issued_to_email_hash, expires_at, status, created_at, updated_at) " +
245
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6, ?7, ?8, 'active', ?9, ?9)",
246
+ [
247
+ id, hash, hint, input.currency, input.amount_minor,
248
+ issuedToCustomerId, issuedToEmailHash, expiresAt, ts,
249
+ ],
250
+ );
251
+
252
+ // `code` is returned plaintext exactly ONCE. The issuer
253
+ // delivers it. Subsequent reads against this row only ever see
254
+ // the hash + hint.
255
+ return {
256
+ id: id,
257
+ code: _formatCode(code),
258
+ code_hint: hint,
259
+ };
260
+ },
261
+
262
+ // Public helper — returns null on no-match (constant-time at the
263
+ // hash layer; see `_lookup`).
264
+ lookup: function (plaintextCode) {
265
+ return _lookup(plaintextCode);
266
+ },
267
+
268
+ balance: async function (plaintextCode) {
269
+ var row = await _lookup(plaintextCode);
270
+ if (!row) return null;
271
+ return {
272
+ balance_minor: row.balance_minor,
273
+ currency: row.currency,
274
+ status: row.status,
275
+ expires_at: row.expires_at,
276
+ };
277
+ },
278
+
279
+ redeem: async function (input) {
280
+ if (!input || typeof input !== "object") {
281
+ throw new TypeError("giftcards.redeem: input object required");
282
+ }
283
+ _amountMinor(input.amount_minor, "amount_minor");
284
+ var orderId = null;
285
+ if (input.order_id != null) orderId = _uuid(input.order_id, "order_id");
286
+
287
+ var row = await _lookup(input.code);
288
+ if (!row) {
289
+ var miss = new Error("giftcards.redeem: code not recognized");
290
+ miss.code = "GIFTCARD_NOT_FOUND";
291
+ throw miss;
292
+ }
293
+ if (row.status !== "active") {
294
+ var inactive = new Error("giftcards.redeem: card is " + row.status);
295
+ inactive.code = "GIFTCARD_NOT_ACTIVE";
296
+ throw inactive;
297
+ }
298
+ var ts = _now();
299
+ if (row.expires_at != null && row.expires_at <= ts) {
300
+ // Expired-but-still-flagged-active: lazily transition the
301
+ // row so future reads reflect reality, then refuse this
302
+ // redemption. The transition itself is idempotent — every
303
+ // `redeem`/`balance` caller does the same check.
304
+ await query(
305
+ "UPDATE giftcards SET status = 'expired', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
306
+ [ts, row.id],
307
+ );
308
+ var exp = new Error("giftcards.redeem: card is expired");
309
+ exp.code = "GIFTCARD_EXPIRED";
310
+ throw exp;
311
+ }
312
+ if (input.amount_minor > row.balance_minor) {
313
+ var ins = new Error("giftcards.redeem: amount exceeds remaining balance");
314
+ ins.code = "GIFTCARD_INSUFFICIENT_BALANCE";
315
+ throw ins;
316
+ }
317
+
318
+ // Atomic decrement guarded by a balance check at the SQL tier
319
+ // so two concurrent redemptions can't double-spend. The
320
+ // `balance_minor >= ?` predicate plus the row-level lock the
321
+ // UPDATE takes means whichever transaction lands second sees
322
+ // rowCount === 0 and we surface as insufficient.
323
+ var dec = await query(
324
+ "UPDATE giftcards SET balance_minor = balance_minor - ?1, " +
325
+ "status = CASE WHEN balance_minor - ?1 = 0 THEN 'redeemed' ELSE status END, " +
326
+ "updated_at = ?2 WHERE id = ?3 AND balance_minor >= ?1 AND status = 'active'",
327
+ [input.amount_minor, ts, row.id],
328
+ );
329
+ if (dec.rowCount === 0) {
330
+ // Race: another redemption beat us to the balance. Refuse
331
+ // with the same shape as the up-front insufficient check so
332
+ // the caller doesn't have to distinguish "checked then
333
+ // raced" from "always insufficient".
334
+ var raced = new Error("giftcards.redeem: amount exceeds remaining balance");
335
+ raced.code = "GIFTCARD_INSUFFICIENT_BALANCE";
336
+ throw raced;
337
+ }
338
+
339
+ var redemptionId = _b().uuid.v7();
340
+ await query(
341
+ "INSERT INTO giftcard_redemptions (id, giftcard_id, order_id, amount_minor, redeemed_at) " +
342
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
343
+ [redemptionId, row.id, orderId, input.amount_minor, ts],
344
+ );
345
+
346
+ var remaining = row.balance_minor - input.amount_minor;
347
+ return {
348
+ remaining_balance_minor: remaining,
349
+ redemption_id: redemptionId,
350
+ };
351
+ },
352
+
353
+ "void": async function (id, opts2) {
354
+ opts2 = opts2 || {};
355
+ _uuid(id, "giftcard id");
356
+ var r = await query(
357
+ "SELECT id, status FROM giftcards WHERE id = ?1",
358
+ [id],
359
+ );
360
+ if (!r.rows.length) return null;
361
+ var row = r.rows[0];
362
+ if (row.status === "redeemed") {
363
+ var already = new Error("giftcards.void: card is fully redeemed");
364
+ already.code = "GIFTCARD_ALREADY_REDEEMED";
365
+ throw already;
366
+ }
367
+ if (row.status === "voided") {
368
+ // Idempotent — already voided; return the row as-is.
369
+ var existing = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
370
+ return existing.rows[0] || null;
371
+ }
372
+ var ts = _now();
373
+ await query(
374
+ "UPDATE giftcards SET status = 'voided', updated_at = ?1 WHERE id = ?2",
375
+ [ts, id],
376
+ );
377
+ // `opts2.reason` is operator-supplied free-form; it's not
378
+ // persisted on the row (no schema column) but accepted so a
379
+ // future audit-log primitive can ride alongside without a
380
+ // surface change.
381
+ void opts2.reason;
382
+ var after = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
383
+ return after.rows[0] || null;
384
+ },
385
+
386
+ listForCustomer: async function (customerId, opts3) {
387
+ _uuid(customerId, "customer_id");
388
+ opts3 = opts3 || {};
389
+ var sql = "SELECT * FROM giftcards WHERE issued_to_customer_id = ?1";
390
+ var params = [customerId];
391
+ if (opts3.status != null) {
392
+ _status(opts3.status);
393
+ sql += " AND status = ?2";
394
+ params.push(opts3.status);
395
+ }
396
+ sql += " ORDER BY created_at DESC";
397
+ var r = await query(sql, params);
398
+ return r.rows;
399
+ },
400
+ };
401
+ }
402
+
403
+ module.exports = {
404
+ create: create,
405
+ CODE_NAMESPACE: CODE_NAMESPACE,
406
+ RECIPIENT_NAMESPACE: RECIPIENT_NAMESPACE,
407
+ CODE_ALPHABET: CODE_ALPHABET,
408
+ CODE_LEN: CODE_LEN,
409
+ STATUSES: STATUSES,
410
+ };
package/lib/index.js CHANGED
@@ -54,4 +54,8 @@ module.exports = {
54
54
  webhooks: require("./webhooks"),
55
55
  analytics: require("./analytics"),
56
56
  inventoryAlerts: require("./inventory-alerts"),
57
+ giftcards: require("./giftcards"),
58
+ reviews: require("./reviews"),
59
+ wishlist: require("./wishlist"),
60
+ inventoryReceive: require("./inventory-receive"),
57
61
  };