← 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
M lib/verify.jsM server.jsM verification/tk11120/resend-corrected.js
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 →