@blamejs/blamejs-shop 0.0.59 → 0.0.60
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/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-portal.js +359 -0
- package/lib/experiments.js +697 -0
- package/lib/index.js +14 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerPortal
|
|
4
|
+
* @title Customer portal sessions — self-serve link minting + redemption
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The framework hosts its OWN customer portal page — addresses,
|
|
8
|
+
* payment methods, subscriptions, open orders — and this primitive
|
|
9
|
+
* is the session manager for the link the customer follows to
|
|
10
|
+
* reach it. It is NOT a Stripe Customer Portal link.
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* 1. Customer requests a portal link from the storefront. The
|
|
14
|
+
* caller (typically a route handler downstream of an email-
|
|
15
|
+
* verification or password challenge) calls
|
|
16
|
+
* `createSession({ customer_id, scope })`.
|
|
17
|
+
* 2. The primitive mints a 32-byte base64url plaintext token via
|
|
18
|
+
* `b.crypto.generateBytes(32)` + `b.crypto.toBase64Url`, hashes
|
|
19
|
+
* it via `b.crypto.namespaceHash("customer-portal-token", ...)`,
|
|
20
|
+
* and writes the hash + scope + 15-min default expiry. The
|
|
21
|
+
* plaintext is returned ONCE; the database never stores it.
|
|
22
|
+
* 3. The customer follows the link to the portal endpoint, which
|
|
23
|
+
* calls `verifyToken(plaintext)`. The primitive re-hashes the
|
|
24
|
+
* plaintext, looks up by hash, runs a `b.crypto.timingSafeEqual`
|
|
25
|
+
* check (belt-and-braces around the PK index), gates on
|
|
26
|
+
* `status === 'issued'` and `expires_at > now`, and on success
|
|
27
|
+
* flips the row to `consumed` (single-use). Returns
|
|
28
|
+
* `{ customer_id, scope, session_id, expires_at }` or null.
|
|
29
|
+
* 4. Operators can revoke a live session early via
|
|
30
|
+
* `revokeSession(session_id, reason)` (e.g. password-reset,
|
|
31
|
+
* session-stolen). The scheduler entry `expireOlderThan(seconds)`
|
|
32
|
+
* flips stale `issued` rows to `expired` so the FSM column
|
|
33
|
+
* stays durable for audit.
|
|
34
|
+
*
|
|
35
|
+
* Composes:
|
|
36
|
+
* - `b.guardUuid` — UUID-shape validation on customer
|
|
37
|
+
* ids + session ids at the entry
|
|
38
|
+
* point; bad shape throws a
|
|
39
|
+
* TypeError the calling route handler
|
|
40
|
+
* translates to HTTP 400.
|
|
41
|
+
* - `b.crypto.generateBytes` — 32-byte CSPRNG draw for the token.
|
|
42
|
+
* - `b.crypto.toBase64Url` — URL-safe encoding for the plaintext.
|
|
43
|
+
* - `b.crypto.namespaceHash` — keys the storage row off
|
|
44
|
+
* `("customer-portal-token", plaintext)`
|
|
45
|
+
* so a dump never reveals the live
|
|
46
|
+
* links the customer received.
|
|
47
|
+
* - `b.crypto.timingSafeEqual` — constant-time check of stored vs
|
|
48
|
+
* recomputed hash on verify.
|
|
49
|
+
* - `b.uuid.v7` — row id; also lexicographically
|
|
50
|
+
* sortable for the listForCustomer
|
|
51
|
+
* tiebreak alongside created_at.
|
|
52
|
+
*
|
|
53
|
+
* Surface:
|
|
54
|
+
* - `createSession({ customer_id, scope, ttl_seconds?, ip_hash?,
|
|
55
|
+
* ua_class? })`
|
|
56
|
+
* → `{ session_id, plaintext_token, expires_at }`
|
|
57
|
+
* - `verifyToken(plaintext)`
|
|
58
|
+
* → `{ customer_id, scope, session_id, expires_at }` or null
|
|
59
|
+
* Flips the row to `consumed` on success (single-use).
|
|
60
|
+
* - `revokeSession(session_id, reason)`
|
|
61
|
+
* → `{ revoked: boolean }`
|
|
62
|
+
* - `listForCustomer(customer_id, { from?, to? })`
|
|
63
|
+
* → array of session rows, newest-first.
|
|
64
|
+
* - `expireOlderThan(seconds)`
|
|
65
|
+
* → `{ expired: <count> }`
|
|
66
|
+
*
|
|
67
|
+
* Storage:
|
|
68
|
+
* - `customer_portal_sessions`
|
|
69
|
+
* (migration `0072_customer_portal_sessions.sql`).
|
|
70
|
+
*
|
|
71
|
+
* @primitive customerPortal
|
|
72
|
+
* @related b.crypto.generateBytes, b.crypto.namespaceHash,
|
|
73
|
+
* b.crypto.timingSafeEqual, b.guardUuid, b.uuid
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
var TOKEN_NAMESPACE = "customer-portal-token";
|
|
77
|
+
var TOKEN_BYTES = 32;
|
|
78
|
+
var DEFAULT_TTL_SECONDS = 15 * 60;
|
|
79
|
+
var MAX_TTL_SECONDS = 60 * 60 * 24; // hard ceiling — one day
|
|
80
|
+
var MIN_TTL_SECONDS = 30; // refuse zero / negative / sub-30s
|
|
81
|
+
var MAX_REASON_LEN = 64;
|
|
82
|
+
var MAX_UA_CLASS_LEN = 64;
|
|
83
|
+
var MAX_IP_HASH_LEN = 256;
|
|
84
|
+
var SCOPE_VALUES = Object.freeze([
|
|
85
|
+
"full",
|
|
86
|
+
"billing_only",
|
|
87
|
+
"address_only",
|
|
88
|
+
"subscriptions_only",
|
|
89
|
+
"order_history_only",
|
|
90
|
+
]);
|
|
91
|
+
var STATUS_VALUES = Object.freeze([
|
|
92
|
+
"issued",
|
|
93
|
+
"consumed",
|
|
94
|
+
"expired",
|
|
95
|
+
"revoked",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// Lazy framework handle — matches the pattern used by the rest of
|
|
99
|
+
// the shop primitives; avoids the `require` cycle that would arise
|
|
100
|
+
// from importing `./index` at module-eval time.
|
|
101
|
+
var bShop;
|
|
102
|
+
function _b() {
|
|
103
|
+
if (!bShop) bShop = require("./index");
|
|
104
|
+
return bShop.framework;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _uuid(s, label) {
|
|
108
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
109
|
+
catch (e) { throw new TypeError("customer-portal: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _scope(s) {
|
|
113
|
+
if (SCOPE_VALUES.indexOf(s) === -1) {
|
|
114
|
+
throw new TypeError("customer-portal: scope must be one of " + SCOPE_VALUES.join(", "));
|
|
115
|
+
}
|
|
116
|
+
return s;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _ttlSeconds(n) {
|
|
120
|
+
if (n == null) return DEFAULT_TTL_SECONDS;
|
|
121
|
+
if (!Number.isInteger(n) || n < MIN_TTL_SECONDS || n > MAX_TTL_SECONDS) {
|
|
122
|
+
throw new TypeError(
|
|
123
|
+
"customer-portal: ttl_seconds must be an integer in [" +
|
|
124
|
+
MIN_TTL_SECONDS + ", " + MAX_TTL_SECONDS + "]"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return n;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _optShortString(s, label, maxLen) {
|
|
131
|
+
if (s == null || s === "") return null;
|
|
132
|
+
if (typeof s !== "string") {
|
|
133
|
+
throw new TypeError("customer-portal: " + label + " must be a string");
|
|
134
|
+
}
|
|
135
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
136
|
+
throw new TypeError("customer-portal: " + label + " must not contain control bytes");
|
|
137
|
+
}
|
|
138
|
+
if (s.length > maxLen) {
|
|
139
|
+
throw new TypeError("customer-portal: " + label + " must be ≤ " + maxLen + " chars");
|
|
140
|
+
}
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _seconds(n, label) {
|
|
145
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
146
|
+
throw new TypeError("customer-portal: " + label + " must be a non-negative integer (seconds)");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _optTsBound(n, label) {
|
|
151
|
+
if (n == null) return null;
|
|
152
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
153
|
+
throw new TypeError("customer-portal: " + label + " must be a non-negative integer (ms epoch)");
|
|
154
|
+
}
|
|
155
|
+
return n;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _now() { return Date.now(); }
|
|
159
|
+
|
|
160
|
+
function create(opts) {
|
|
161
|
+
opts = opts || {};
|
|
162
|
+
var query = opts.query;
|
|
163
|
+
if (!query) {
|
|
164
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
SCOPE_VALUES: SCOPE_VALUES,
|
|
169
|
+
STATUS_VALUES: STATUS_VALUES,
|
|
170
|
+
DEFAULT_TTL_SECONDS: DEFAULT_TTL_SECONDS,
|
|
171
|
+
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
172
|
+
|
|
173
|
+
// Mint a fresh portal session. The plaintext token is returned
|
|
174
|
+
// ONCE; the database stores only the namespaceHash. Callers pass
|
|
175
|
+
// the plaintext into the email body / SMS template that delivers
|
|
176
|
+
// the portal URL — never log it, never persist it server-side.
|
|
177
|
+
//
|
|
178
|
+
// Inputs are validated up-front (UUID shape on customer_id,
|
|
179
|
+
// enum check on scope, integer-range check on ttl_seconds,
|
|
180
|
+
// short-string + control-byte check on ip_hash / ua_class).
|
|
181
|
+
// Bad input throws TypeError so the route handler turns into
|
|
182
|
+
// HTTP 400 cleanly.
|
|
183
|
+
createSession: async function (input) {
|
|
184
|
+
if (!input || typeof input !== "object") {
|
|
185
|
+
throw new TypeError("customer-portal.createSession: input object required");
|
|
186
|
+
}
|
|
187
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
188
|
+
var scope = _scope(input.scope);
|
|
189
|
+
var ttl = _ttlSeconds(input.ttl_seconds);
|
|
190
|
+
var ipHash = _optShortString(input.ip_hash, "ip_hash", MAX_IP_HASH_LEN);
|
|
191
|
+
var uaClass = _optShortString(input.ua_class, "ua_class", MAX_UA_CLASS_LEN);
|
|
192
|
+
|
|
193
|
+
var plaintext = _b().crypto.toBase64Url(
|
|
194
|
+
_b().crypto.generateBytes(TOKEN_BYTES)
|
|
195
|
+
);
|
|
196
|
+
var tokenHash = _b().crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
197
|
+
var id = _b().uuid.v7();
|
|
198
|
+
var now = _now();
|
|
199
|
+
var expiresAt = now + (ttl * 1000);
|
|
200
|
+
|
|
201
|
+
await query(
|
|
202
|
+
"INSERT INTO customer_portal_sessions " +
|
|
203
|
+
"(id, customer_id, token_hash, scope, status, " +
|
|
204
|
+
" consumed_at, revoked_at, revoke_reason, ip_hash, ua_class, " +
|
|
205
|
+
" created_at, expires_at) " +
|
|
206
|
+
"VALUES (?1, ?2, ?3, ?4, 'issued', NULL, NULL, NULL, ?5, ?6, ?7, ?8)",
|
|
207
|
+
[id, customerId, tokenHash, scope, ipHash, uaClass, now, expiresAt],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// The plaintext leaves this function exactly once. The row
|
|
211
|
+
// above is the only durable handle to the session; the hash
|
|
212
|
+
// makes the storage layer useless for replaying live links.
|
|
213
|
+
return {
|
|
214
|
+
session_id: id,
|
|
215
|
+
plaintext_token: plaintext,
|
|
216
|
+
expires_at: expiresAt,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
// Single-use redemption. Returns `null` on every failure mode
|
|
221
|
+
// (unknown token, expired, consumed, revoked, malformed input) —
|
|
222
|
+
// the caller is typically a route handler that wants to render a
|
|
223
|
+
// single "request a fresh link" page regardless of why. Latency
|
|
224
|
+
// between the miss-path and the hit-path is equalised by burning
|
|
225
|
+
// a constant-shape `timingSafeEqual` even on the miss branch so
|
|
226
|
+
// an attacker can't distinguish "no such token" from "already-
|
|
227
|
+
// consumed" through the response-latency channel.
|
|
228
|
+
//
|
|
229
|
+
// On success the row's status flips to `consumed` and
|
|
230
|
+
// `consumed_at` is stamped. A second call with the same
|
|
231
|
+
// plaintext misses on the status gate.
|
|
232
|
+
verifyToken: async function (plaintext) {
|
|
233
|
+
if (typeof plaintext !== "string" || !plaintext.length) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
var tokenHash = _b().crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
237
|
+
var row = (await query(
|
|
238
|
+
"SELECT id, customer_id, token_hash, scope, status, expires_at " +
|
|
239
|
+
"FROM customer_portal_sessions WHERE token_hash = ?1 LIMIT 1",
|
|
240
|
+
[tokenHash],
|
|
241
|
+
)).rows[0];
|
|
242
|
+
if (!row) {
|
|
243
|
+
// Burn the same comparison work as the hit path so the
|
|
244
|
+
// latency profile matches. The compared values are
|
|
245
|
+
// deliberately equal-length and equal (the hash against
|
|
246
|
+
// itself); the result is discarded.
|
|
247
|
+
_b().crypto.timingSafeEqual(tokenHash, tokenHash);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
var matched = _b().crypto.timingSafeEqual(row.token_hash, tokenHash);
|
|
251
|
+
if (!matched) return null;
|
|
252
|
+
if (row.status !== "issued") return null;
|
|
253
|
+
var now = _now();
|
|
254
|
+
if (Number(row.expires_at) <= now) return null;
|
|
255
|
+
|
|
256
|
+
// Single-use flip. The WHERE clause re-asserts `status =
|
|
257
|
+
// 'issued'` so two racing verifies don't both succeed — the
|
|
258
|
+
// second one's UPDATE matches zero rows and we treat that as
|
|
259
|
+
// already-consumed (return null).
|
|
260
|
+
var upd = await query(
|
|
261
|
+
"UPDATE customer_portal_sessions " +
|
|
262
|
+
"SET status = 'consumed', consumed_at = ?1 " +
|
|
263
|
+
"WHERE id = ?2 AND status = 'issued'",
|
|
264
|
+
[now, row.id],
|
|
265
|
+
);
|
|
266
|
+
if (Number(upd.rowCount || 0) === 0) return null;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
customer_id: row.customer_id,
|
|
270
|
+
scope: row.scope,
|
|
271
|
+
session_id: row.id,
|
|
272
|
+
expires_at: Number(row.expires_at),
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// Operator-initiated kill. Idempotent — calling on an already-
|
|
277
|
+
// terminal row (consumed / expired / revoked) returns
|
|
278
|
+
// `{ revoked: false }` rather than throwing, so the caller can
|
|
279
|
+
// safely loop over a list of session ids during a bulk eject
|
|
280
|
+
// (password reset across every live session for a customer).
|
|
281
|
+
revokeSession: async function (sessionId, reason) {
|
|
282
|
+
var id = _uuid(sessionId, "session_id");
|
|
283
|
+
var clean = _optShortString(reason, "reason", MAX_REASON_LEN);
|
|
284
|
+
if (clean == null) {
|
|
285
|
+
throw new TypeError("customer-portal.revokeSession: reason required (non-empty string ≤ " + MAX_REASON_LEN + " chars)");
|
|
286
|
+
}
|
|
287
|
+
var now = _now();
|
|
288
|
+
var r = await query(
|
|
289
|
+
"UPDATE customer_portal_sessions " +
|
|
290
|
+
"SET status = 'revoked', revoked_at = ?1, revoke_reason = ?2 " +
|
|
291
|
+
"WHERE id = ?3 AND status = 'issued'",
|
|
292
|
+
[now, clean, id],
|
|
293
|
+
);
|
|
294
|
+
return { revoked: Number(r.rowCount || 0) > 0 };
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
// Audit / debugging entry point. Returns every session ever
|
|
298
|
+
// minted for a customer, newest-first by created_at (id as
|
|
299
|
+
// tiebreak — both are uuid.v7-derived so the ordering is
|
|
300
|
+
// monotonic). Optional `from` / `to` bounds (ms epoch, inclusive
|
|
301
|
+
// lower / exclusive upper) constrain the window without a full
|
|
302
|
+
// table scan thanks to the (customer_id, created_at desc) index.
|
|
303
|
+
listForCustomer: async function (customerId, listOpts) {
|
|
304
|
+
var cid = _uuid(customerId, "customer_id");
|
|
305
|
+
listOpts = listOpts || {};
|
|
306
|
+
if (typeof listOpts !== "object") {
|
|
307
|
+
throw new TypeError("customer-portal.listForCustomer: opts must be an object");
|
|
308
|
+
}
|
|
309
|
+
var from = _optTsBound(listOpts.from, "from");
|
|
310
|
+
var to = _optTsBound(listOpts.to, "to");
|
|
311
|
+
|
|
312
|
+
var sql = "SELECT id, customer_id, scope, status, " +
|
|
313
|
+
"consumed_at, revoked_at, revoke_reason, " +
|
|
314
|
+
"ip_hash, ua_class, created_at, expires_at " +
|
|
315
|
+
"FROM customer_portal_sessions WHERE customer_id = ?1";
|
|
316
|
+
var params = [cid];
|
|
317
|
+
if (from != null) {
|
|
318
|
+
params.push(from);
|
|
319
|
+
sql += " AND created_at >= ?" + params.length;
|
|
320
|
+
}
|
|
321
|
+
if (to != null) {
|
|
322
|
+
params.push(to);
|
|
323
|
+
sql += " AND created_at < ?" + params.length;
|
|
324
|
+
}
|
|
325
|
+
sql += " ORDER BY created_at DESC, id DESC";
|
|
326
|
+
var r = await query(sql, params);
|
|
327
|
+
return r.rows;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// Scheduler walk: flip every `issued` row whose `expires_at` is
|
|
331
|
+
// more than `seconds` in the past from `expired`. The lazy gate
|
|
332
|
+
// — verifyToken always re-checks `expires_at > now` regardless
|
|
333
|
+
// — but the durable FSM stamp keeps audit / "show me every live
|
|
334
|
+
// session" queries cheap (status = 'issued' implies "redeemable
|
|
335
|
+
// right now" rather than "issued at some point in the past").
|
|
336
|
+
//
|
|
337
|
+
// Returns the count of rows flipped so the cron / scheduled-
|
|
338
|
+
// worker layer can emit a metric.
|
|
339
|
+
expireOlderThan: async function (seconds) {
|
|
340
|
+
_seconds(seconds, "seconds");
|
|
341
|
+
var threshold = _now() - (seconds * 1000);
|
|
342
|
+
var r = await query(
|
|
343
|
+
"UPDATE customer_portal_sessions " +
|
|
344
|
+
"SET status = 'expired' " +
|
|
345
|
+
"WHERE status = 'issued' AND expires_at < ?1",
|
|
346
|
+
[threshold],
|
|
347
|
+
);
|
|
348
|
+
return { expired: Number(r.rowCount || 0) };
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = {
|
|
354
|
+
create: create,
|
|
355
|
+
SCOPE_VALUES: SCOPE_VALUES,
|
|
356
|
+
STATUS_VALUES: STATUS_VALUES,
|
|
357
|
+
DEFAULT_TTL_SECONDS: DEFAULT_TTL_SECONDS,
|
|
358
|
+
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
359
|
+
};
|