← 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 };