@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.
@@ -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
+ };