← back to Dw Signup Fulfillment
lib/verify.js
134 lines
'use strict';
// Retail free-samples via DOUBLE OPT-IN + tag-gated pricing (Option C — DTD verdict,
// 2026-08-14). This REPLACES the gift-card path (giftcard.js / mint-ledger.js) and the
// shared-coupon path (retail-code.js), which are kept only as reference alternates.
//
// A new/claiming customer is NOT given stored value. Instead:
// 1) startVerification() emails a branded "confirm your email to unlock N free
// samples" link carrying a SIGNED, STATELESS token (HMAC — no DB, no nonce store).
// 2) On click, /verify -> completeVerification() appends VERIFIED_TAG to the Shopify
// customer. The store's tag-gated sample discount (the SAME Regios mechanism that
// already makes `trade` memos free — scoped to the "Sample" variant, so the $80+
// roll variant is never touched) then shows their samples free at checkout.
//
// Why this beats the old design: no gift-card liability, no ledger, no daily mint cap,
// no shared coupon that leaks to coupon sites, no Shopify Function / Plus dependency. A
// customer tag is durable and non-redeemable by a stranger. Re-applying the tag is a
// no-op, so the whole flow is idempotent and safe to replay.
//
// DRY_RUN-safe end to end: shopify.* writes and email.sendEmail are short-circuited by
// their own modules when config.DRY_RUN is true.
const crypto = require('crypto');
const config = require('./config');
const shopify = require('./shopify');
const email = require('./email');
// Dev-only fallback secret so tokens round-trip in DRY_RUN without provisioning one.
// In LIVE a missing VERIFY_SECRET fails CLOSED (mintToken/readToken return no_secret,
// /verify 503s) — a missing key disables the reward rather than trusting a guessable one.
const DEV_SECRET = 'dw-signup-verify-dev-secret-DRYRUN-ONLY';
function secret() { return config.VERIFY_SECRET || (config.DRY_RUN ? DEV_SECRET : ''); }
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function fromB64url(s) { return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
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, count }) {
const sec = secret();
if (!sec) return null;
const payload = {
e: String(addr || '').trim().toLowerCase(),
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);
}
// readToken(token) -> { ok:true, email, customerId } | { ok:false, reason }
function readToken(token) {
const sec = secret();
if (!sec) return { ok: false, reason: 'no_secret' };
if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return { ok: false, reason: 'malformed' };
const [pB64, sig] = token.split('.');
const expect = sign(pB64, sec);
const a = Buffer.from(String(sig || '')), b = Buffer.from(expect);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'bad_signature' };
let payload;
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, 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
// must NEVER fall back to the loopback host — a `http://127.0.0.1:PORT/verify` link
// mailed to a customer is a dead link (this is exactly what shipped to ~75 retail
// signups on 2026-09-02 when a backfill job ran with PUBLIC_URL unset). Only DRY_RUN/dev
// may use the loopback base; in LIVE an empty PUBLIC_URL returns '' so callers REFUSE to
// send rather than send a broken link — same philosophy as the no_secret guard above.
function baseUrl() {
if (config.PUBLIC_URL) return config.PUBLIC_URL;
if (config.DRY_RUN) return `http://127.0.0.1:${config.PORT}`;
return '';
}
// Step 1 — email the branded "confirm your email" letter with the verify link.
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, 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' };
}
const base = baseUrl();
if (!base) {
console.warn('[verify] PUBLIC_URL unset in LIVE — refusing to send (would ship a dead localhost verify link). Set PUBLIC_URL and retry.');
return { ok: false, reason: 'no_public_url' };
}
const url = `${base}/verify?token=${encodeURIComponent(token)}`;
const first = firstName || (addr.includes('@') ? addr.split('@')[0] : '');
const tpl = email.verifyEmail({ firstName: first, url, count: config.FREE_SAMPLE_COUNT });
const mail = await email.sendEmail({ to: addr, subject: tpl.subject, html: tpl.html, source: 'retail-verify' });
// CREDENTIAL-SAFE: never let a failed send hide again. Log + PROPAGATE the George reason/
// status so retail-webhook.js records *why* the letter didn't go out. Never logs the
// recipient, verify token (carried in `url`), auth header, or George response body.
if (mail && mail.ok === false) {
console.warn(`[verify] verify-email SEND FAILED status=${mail.status != null ? mail.status : '?'} errorCode=${mail.errorCode || mail.error || ''}`);
return { ok: false, reason: 'send_failed', status: (mail.status != null ? mail.status : null), errorCode: mail.errorCode || mail.error || null, sent: { to: addr, subject: tpl.subject, dryRun: mail.dryRun || false } };
}
// verifyUrl carries the bearer token — callers must redact it before logging.
return { ok: true, sent: { to: addr, subject: tpl.subject, dryRun: mail.dryRun || false }, verifyUrl: url };
}
// Step 2 — apply the tag-gated sample entitlement after a valid click. Idempotent.
async function completeVerification({ email: addr, customerId }) {
let custId = customerId || null;
if (!custId) custId = await shopify.findCustomerByEmail(addr);
if (!custId) return { ok: false, reason: 'customer_not_found', email: addr };
// Detect first-time vs a re-click of the same verify link. The tag write is idempotent,
// but the "samples unlocked" confirmation email is NOT — without this, clicking the link
// three times sends three emails. Read current tags first so the caller can fire the
// confirmation exactly once. Failure to read defaults firstTime=true (fail toward the
// customer getting their email, not toward silence).
let alreadyTagged = false;
try {
const g = await shopify.getCustomer(custId);
const tags = (g && g.json && g.json.customer && g.json.customer.tags) || '';
alreadyTagged = tags.split(',').map((t) => t.trim().toLowerCase()).includes(String(config.VERIFIED_TAG).toLowerCase());
} catch (e) { /* non-fatal */ }
const tagRes = await shopify.addTags(custId, [config.VERIFIED_TAG]);
// Best-effort audit flag; never fatal (the tag is what actually gates the discount).
try { await shopify.setCustomerMetafield(custId, { namespace: 'custom', key: 'sample_verified', value: 'true', type: 'boolean' }); } catch (e) { /* non-fatal */ }
return { ok: tagRes.ok !== false, customerId: custId, tag: config.VERIFIED_TAG, dryRun: tagRes.dryRun || false, firstTime: !alreadyTagged };
}
module.exports = { mintToken, readToken, startVerification, completeVerification, baseUrl };