[object Object]

← back to Dw Signup Fulfillment

TK-11120: verify links fail-closed on missing PUBLIC_URL + send as info@ (retail-verify localhost-link outage)

0016d65541cf0d10f5e94035de511d75af37e869 · 2026-09-02 13:48:35 -0700 · Steve Abrams

Root cause of the 2026-09-02 retail-verify blast: a backfill job ran with
PUBLIC_URL unset, so verify.baseUrl() fell back to http://127.0.0.1:9856 and
~75 customers got a dead localhost 'Confirm my email' link. Also sent as
steve-office (Gmail rewrote From to steve@ since info@ isn't a send-as alias).

- verify.baseUrl(): FAIL-CLOSED in LIVE — never fall back to loopback; empty
  PUBLIC_URL returns '' and startVerification refuses to send (reason=no_public_url).
- config.GEORGE_ACCOUNT default steve-office -> info, so mail is genuinely from info@.

No send, no deploy in this commit — code hardening only.

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

Files touched

Diff

commit 0016d65541cf0d10f5e94035de511d75af37e869
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 13:48:35 2026 -0700

    TK-11120: verify links fail-closed on missing PUBLIC_URL + send as info@ (retail-verify localhost-link outage)
    
    Root cause of the 2026-09-02 retail-verify blast: a backfill job ran with
    PUBLIC_URL unset, so verify.baseUrl() fell back to http://127.0.0.1:9856 and
    ~75 customers got a dead localhost 'Confirm my email' link. Also sent as
    steve-office (Gmail rewrote From to steve@ since info@ isn't a send-as alias).
    
    - verify.baseUrl(): FAIL-CLOSED in LIVE — never fall back to loopback; empty
      PUBLIC_URL returns '' and startVerification refuses to send (reason=no_public_url).
    - config.GEORGE_ACCOUNT default steve-office -> info, so mail is genuinely from info@.
    
    No send, no deploy in this commit — code hardening only.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017j4qS38tWq21qYdjxMcFTy
---
 lib/config.js | 12 +++++++-----
 lib/verify.js | 19 +++++++++++++++++--
 2 files changed, 24 insertions(+), 7 deletions(-)

diff --git a/lib/config.js b/lib/config.js
index 8a35842..e52e9b7 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -93,11 +93,13 @@ const config = {
 
   // George email sender.
   GEORGE_URL: process.env.GEORGE_URL || 'http://127.0.0.1:9850',
-  // Account that authenticates to George (steve-office Gmail). The FROM address is the
-  // monitored office inbox so customer/designer replies land there, not Steve's personal
-  // box. GO-LIVE: confirm info@designerwallcoverings.com is a verified send-as alias in
-  // the steve-office Gmail, else Gmail rewrites the From to the authenticated address.
-  GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'steve-office',
+  // Account that AUTHENTICATES to George. MUST be 'info' (info@designerwallcoverings.com)
+  // so the visible From is genuinely info@ — Steve's directive 2026-09-02 (TK-11120).
+  // The earlier default 'steve-office' made George send AS steve-office and, because
+  // info@ is NOT a verified send-as alias on that mailbox, Gmail rewrote the visible From
+  // back to steve@designerwallcoverings.com — so the retail-verify blast appeared to come
+  // from Steve personally. Authenticating as the info mailbox fixes the sender identity.
+  GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'info',
   GEORGE_FROM: process.env.GEORGE_FROM || 'info@designerwallcoverings.com',
   GEORGE_EXTERNAL_SEND_TOKEN: firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS),
   // Basic-auth credential for George. Resolve from the SAME source the working
diff --git a/lib/verify.js b/lib/verify.js
index 356cdcd..7a23df0 100644
--- a/lib/verify.js
+++ b/lib/verify.js
@@ -62,7 +62,17 @@ function readToken(token) {
   return { ok: true, email: payload.e, customerId: payload.c || null };
 }
 
-function baseUrl() { return config.PUBLIC_URL || `http://127.0.0.1:${config.PORT}`; }
+// 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 }) {
@@ -73,7 +83,12 @@ async function startVerification({ email: to, customerId, firstName }) {
     console.warn('[verify] VERIFY_SECRET unset (live) — cannot mint verify token; skipping send.');
     return { ok: false, reason: 'no_secret' };
   }
-  const url = `${baseUrl()}/verify?token=${encodeURIComponent(token)}`;
+  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' });

← d8959b9 TK-11114: backfill sent (68/68) — outage fully remediated  ·  back to Dw Signup Fulfillment  ·  TK-11120: corrected resend tool + apology template (retail-v a6edf22 →