[object Object]

← back to Dw Signup Fulfillment

TK-11120 Option B: verify page shows the token-carried sample count (5 for the retail cohort)

d1ffb0fd13d8d027e5c87810a673a35c3e18d0da · 2026-09-02 15:14:47 -0700 · Steve Abrams

Tokens now carry an optional per-link count (payload.n); readToken returns it; server.js
/verify success page + samplesUnlockedEmail use parsed.count ?? FREE_SAMPLE_COUNT, so these
customers see '5' while the global default stays 3. startVerification + resend-corrected
mint with count. Backward compatible: tokens without n fall back to the global default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017j4qS38tWq21qYdjxMcFTy

Files touched

Diff

commit d1ffb0fd13d8d027e5c87810a673a35c3e18d0da
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 15:14:47 2026 -0700

    TK-11120 Option B: verify page shows the token-carried sample count (5 for the retail cohort)
    
    Tokens now carry an optional per-link count (payload.n); readToken returns it; server.js
    /verify success page + samplesUnlockedEmail use parsed.count ?? FREE_SAMPLE_COUNT, so these
    customers see '5' while the global default stays 3. startVerification + resend-corrected
    mint with count. Backward compatible: tokens without n fall back to the global default.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017j4qS38tWq21qYdjxMcFTy
---
 lib/verify.js                            | 11 ++++++++---
 server.js                                |  6 ++++--
 verification/tk11120/resend-corrected.js |  2 +-
 3 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/lib/verify.js b/lib/verify.js
index 7a23df0..0abd57f 100644
--- a/lib/verify.js
+++ b/lib/verify.js
@@ -34,7 +34,7 @@ function fromB64url(s) { return Buffer.from(String(s).replace(/-/g, '+').replace
 function sign(payloadB64, sec) { return b64url(crypto.createHmac('sha256', sec).update(payloadB64).digest()); }
 
 // mintToken({email, customerId}) -> "payload.sig" (base64url), or null with no secret (live).
-function mintToken({ email: addr, customerId }) {
+function mintToken({ email: addr, customerId, count }) {
   const sec = secret();
   if (!sec) return null;
   const payload = {
@@ -42,6 +42,11 @@ function mintToken({ email: addr, customerId }) {
     c: customerId ? String(customerId) : '',
     x: Date.now() + config.VERIFY_TTL_HOURS * 3600 * 1000,
   };
+  // TK-11120 (Option B): carry the promised sample count in the token so the /verify
+  // confirm page shows the SAME number the email promised (e.g. 5 for the retail
+  // apology cohort), without changing the global FREE_SAMPLE_COUNT for everyone else.
+  const n = parseInt(count, 10);
+  if (Number.isFinite(n) && n > 0) payload.n = n;
   const pB64 = b64url(JSON.stringify(payload));
   return pB64 + '.' + sign(pB64, sec);
 }
@@ -59,7 +64,7 @@ function readToken(token) {
   try { payload = JSON.parse(fromB64url(pB64).toString('utf8')); } catch { return { ok: false, reason: 'bad_payload' }; }
   if (!payload || typeof payload.x !== 'number' || Date.now() > payload.x) return { ok: false, reason: 'expired' };
   if (!payload.e) return { ok: false, reason: 'no_email' };
-  return { ok: true, email: payload.e, customerId: payload.c || null };
+  return { ok: true, email: payload.e, customerId: payload.c || null, count: (Number.isFinite(payload.n) && payload.n > 0 ? payload.n : null) };
 }
 
 // Public base for the verify link. FAIL-CLOSED in LIVE (TK-11120): a missing PUBLIC_URL
@@ -78,7 +83,7 @@ function baseUrl() {
 async function startVerification({ email: to, customerId, firstName }) {
   const addr = String(to || '').trim().toLowerCase();
   if (!addr) return { ok: false, reason: 'no_email' };
-  const token = mintToken({ email: addr, customerId });
+  const token = mintToken({ email: addr, customerId, count: config.FREE_SAMPLE_COUNT });
   if (!token) {
     console.warn('[verify] VERIFY_SECRET unset (live) — cannot mint verify token; skipping send.');
     return { ok: false, reason: 'no_secret' };
diff --git a/server.js b/server.js
index 1b32382..207866c 100644
--- a/server.js
+++ b/server.js
@@ -195,6 +195,8 @@ app.get('/verify', async (req, res) => {
     return res.status(parsed.reason === 'no_secret' ? 503 : 200).type('html').send(verifyPage(msg, false));
   }
   const done = await verify.completeVerification({ email: parsed.email, customerId: parsed.customerId });
+  // TK-11120 (Option B): show the count the email promised (token-carried), else global default.
+  const cnt = parsed.count || config.FREE_SAMPLE_COUNT;
   if (!done.ok) {
     return res.status(200).type('html').send(verifyPage("We couldn't attach the samples to your account. Please make sure you're signed in with this email and try again — or reply to our email and we'll sort it out.", false));
   }
@@ -202,7 +204,7 @@ app.get('/verify', async (req, res) => {
   // send duplicate "samples unlocked" emails (the tag write is idempotent; the email isn't).
   if (done.firstTime) {
     (async () => {
-      const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: config.FREE_SAMPLE_COUNT });
+      const t = email.samplesUnlockedEmail({ firstName: parsed.email.split('@')[0], count: cnt });
       const r = await email.sendEmail({ to: parsed.email, subject: t.subject, html: t.html, source: 'retail-verified' });
       if (r && r.ok === false) console.error(`[verify] unlocked-email send FAILED for ${parsed.email}: ${r.error || r.status}`);
     })().catch(e => console.error('[verify] unlocked-email error:', e.message));
@@ -210,7 +212,7 @@ app.get('/verify', async (req, res) => {
     console.log(`[verify] repeat click for customer ${done.customerId} — tag re-applied, confirmation email skipped (already sent).`);
   }
   console.log(`[verify] tagged customer ${done.customerId} '${done.tag}'${done.dryRun ? ' (DRY_RUN)' : ''}`);
-  res.type('html').send(verifyPage(`Email confirmed. Sign in with this address and your remaining ${config.FREE_SAMPLE_COUNT}-sample lifetime allowance applies automatically at checkout.`, true));
+  res.type('html').send(verifyPage(`Email confirmed. Sign in with this address and your remaining ${cnt}-sample lifetime allowance applies automatically at checkout.`, true));
 });
 
 // Public base the emailed Approve/Reject buttons point at (Kamatera host at go-live).
diff --git a/verification/tk11120/resend-corrected.js b/verification/tk11120/resend-corrected.js
index 8b28107..ec1f6a5 100644
--- a/verification/tk11120/resend-corrected.js
+++ b/verification/tk11120/resend-corrected.js
@@ -64,7 +64,7 @@ function mask(e) { return String(e).replace(/(.{2}).*@/, '$1***@'); }
     // RETAIL-ONLY (Steve): trade/designer accounts get UNLIMITED samples, so the 5-sample offer doesn't apply.
     if (RETAIL_ONLY && tags.split(',').map(t => t.trim().toLowerCase()).includes('trade')) { skipped++; console.log(`  [skip] ${mask(addr)} trade account (retail-only)`); continue; }
     n++;
-    const token = verify.mintToken({ email: addr, customerId });
+    const token = verify.mintToken({ email: addr, customerId, count: config.FREE_SAMPLE_COUNT });
     if (!token) { failed++; ledgerAppend({ email: addr, ok: false, reason: 'no_token(secret?)', ts: new Date().toISOString() }); console.log(`  [FAIL] ${mask(addr)} could not mint token (VERIFY_SECRET?)`); continue; }
     const url = `${base}/verify?token=${encodeURIComponent(token)}`;
     const tpl = email.verifyResendEmail({ firstName: firstName || addr.split('@')[0], url, count: config.FREE_SAMPLE_COUNT });

← 0b127cf TK-11120: retail-only 5-sample apology resend (--force, --re  ·  back to Dw Signup Fulfillment  ·  TK-11120: definitive checkout test — verified-sample caps at 48d0832 →