@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/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 = "newsletter-email";
32
- var MAX_SOURCE_LEN = 64;
33
- var SOURCE_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$/;
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, empty input, oversized input, and RFC-shape
51
- // violations; we just hand it through with the trim already
52
- // applied so the canonical form lands in storage.
53
- var checked = _b().guardEmail(trimmed, { profile: "strict" });
54
- return checked.toLowerCase();
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