[object Object]

← back to Wallco Ai

trade-login: add password sign-in (env-var gated) alongside magic-link

759068a0c896ca41dc7d024c7015aed7d8fa401a · 2026-05-29 13:56:15 -0700 · Steve Abrams

Steve 2026-05-29 'update so the un and pw work'. Magic-link still works
(SMTP now wired on prod via env), but adds a parallel password path that
bypasses email entirely for admin convenience.

new on /trade/login: hr-separated 'Or sign in with password' section
under the existing email form. Email + password fields, posts to
POST /api/trade/login-pw with the same returnTo round-trip.

new route POST /api/trade/login-pw: env-var gated single-user admin
bypass. Reads TRADE_ADMIN_EMAIL + TRADE_ADMIN_PASS, constant-time
compares via crypto.timingSafeEqual after length-equal check (length
leak acceptable vs returning early on mismatch). On match: upserts
wallco_trade_users row, mints the SAME 14-day JWT + cookie as the
magic-link /trade/auth handler, applies same admin-promotion via
WALLCO_ADMIN_EMAILS, logs trade_login_pw_success to wallco_audit_log.
On fail: logs trade_login_pw_fail + 600ms delay + 401.

No bcrypt, no schema change. Security model = HTTP basic equivalent
for a single-user env-var bypass. Multi-user/proper hashed creds is
a future change (Option B2 from the debug-agent analysis); deferred
until trade signup is actually live.

verified Mac2:
  - wrong pw → 401 'Invalid email or password.'
  - correct pw → 200 ok:true + Set-Cookie wallco_trade_session +
    Set-Cookie dw_auth (admin-promote landed because Steve's email
    is in WALLCO_ADMIN_EMAILS)
  - GET /trade/login serves the new form

verified prod (surgical port via python str.replace, both anchors
found exactly once + node -c passed before scp):
  - POST /api/trade/login-pw without env vars → 503 'not configured'
    (route loaded but TRADE_ADMIN_PASS not yet set on prod)
  - GET /trade/login serves the new form

still pending: TRADE_ADMIN_EMAIL + TRADE_ADMIN_PASS in prod /root/
public-projects/wallco-ai/.env. Steve to provide the password value
out-of-band; will append + pm2 reload --update-env when received.

Files touched

Diff

commit 759068a0c896ca41dc7d024c7015aed7d8fa401a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 13:56:15 2026 -0700

    trade-login: add password sign-in (env-var gated) alongside magic-link
    
    Steve 2026-05-29 'update so the un and pw work'. Magic-link still works
    (SMTP now wired on prod via env), but adds a parallel password path that
    bypasses email entirely for admin convenience.
    
    new on /trade/login: hr-separated 'Or sign in with password' section
    under the existing email form. Email + password fields, posts to
    POST /api/trade/login-pw with the same returnTo round-trip.
    
    new route POST /api/trade/login-pw: env-var gated single-user admin
    bypass. Reads TRADE_ADMIN_EMAIL + TRADE_ADMIN_PASS, constant-time
    compares via crypto.timingSafeEqual after length-equal check (length
    leak acceptable vs returning early on mismatch). On match: upserts
    wallco_trade_users row, mints the SAME 14-day JWT + cookie as the
    magic-link /trade/auth handler, applies same admin-promotion via
    WALLCO_ADMIN_EMAILS, logs trade_login_pw_success to wallco_audit_log.
    On fail: logs trade_login_pw_fail + 600ms delay + 401.
    
    No bcrypt, no schema change. Security model = HTTP basic equivalent
    for a single-user env-var bypass. Multi-user/proper hashed creds is
    a future change (Option B2 from the debug-agent analysis); deferred
    until trade signup is actually live.
    
    verified Mac2:
      - wrong pw → 401 'Invalid email or password.'
      - correct pw → 200 ok:true + Set-Cookie wallco_trade_session +
        Set-Cookie dw_auth (admin-promote landed because Steve's email
        is in WALLCO_ADMIN_EMAILS)
      - GET /trade/login serves the new form
    
    verified prod (surgical port via python str.replace, both anchors
    found exactly once + node -c passed before scp):
      - POST /api/trade/login-pw without env vars → 503 'not configured'
        (route loaded but TRADE_ADMIN_PASS not yet set on prod)
      - GET /trade/login serves the new form
    
    still pending: TRADE_ADMIN_EMAIL + TRADE_ADMIN_PASS in prod /root/
    public-projects/wallco-ai/.env. Steve to provide the password value
    out-of-band; will append + pm2 reload --update-env when received.
---
 server.js | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 151 insertions(+)

diff --git a/server.js b/server.js
index 00cd4e8..c51d6fe 100644
--- a/server.js
+++ b/server.js
@@ -3821,6 +3821,28 @@ ${htmlHeader('/trade/login')}
       </button>
     </form>
     <div id="trade-login-msg" role="status" aria-live="polite" style="margin-top:16px;font:13px/1.5 var(--sans);color:var(--ink-soft,#555)"></div>
+
+    <!-- Password sign-in (Steve 2026-05-29: directive "update so the un and pw work"). Env-var
+         gated admin bypass for when email isn't deliverable. Single-user via TRADE_ADMIN_EMAIL
+         + TRADE_ADMIN_PASS. Constant-time compare server-side; same JWT cookie as /trade/auth. -->
+    <hr style="margin:28px 0;border:0;border-top:1px solid var(--line,#ddd)">
+    <p style="font:11px/1.5 var(--sans);color:var(--ink-faint,#888);margin:0 0 14px;text-align:center;letter-spacing:.08em;text-transform:uppercase">Or sign in with password</p>
+    <form id="trade-pw-form" autocomplete="on" novalidate>
+      <input type="hidden" name="return" value="${_escTrade(returnTo)}">
+      <label for="trade-pw-email" style="display:block;font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);margin-bottom:6px">Email</label>
+      <input type="email" id="trade-pw-email" name="email" required
+             placeholder="you@studio.com" autocomplete="username"
+             style="width:100%;padding:14px 16px;font:16px var(--sans,system-ui);border:1px solid var(--line,#ddd);border-radius:6px;margin-bottom:10px;box-sizing:border-box">
+      <label for="trade-pw-pass" style="display:block;font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);margin-bottom:6px">Password</label>
+      <input type="password" id="trade-pw-pass" name="password" required
+             autocomplete="current-password"
+             style="width:100%;padding:14px 16px;font:16px var(--sans,system-ui);border:1px solid var(--line,#ddd);border-radius:6px;margin-bottom:10px;box-sizing:border-box">
+      <button type="submit" class="btn-primary" style="width:100%;padding:14px;font:14px var(--sans);cursor:pointer">
+        Sign in
+      </button>
+    </form>
+    <div id="trade-pw-msg" role="status" aria-live="polite" style="margin-top:12px;font:13px/1.5 var(--sans);color:var(--ink-soft,#555)"></div>
+
     <p style="margin-top:32px;font:11.5px/1.5 var(--sans);color:var(--ink-faint,#888)">
       By signing in, you agree to receive trade-only communications from wallco.ai
       (part of Designer Wallcoverings). We never sell your data.
@@ -3830,6 +3852,34 @@ ${htmlHeader('/trade/login')}
 ${FOOTER}
 <script>
 (function(){
+  var pwForm = document.getElementById('trade-pw-form');
+  var pwMsg  = document.getElementById('trade-pw-msg');
+  if (pwForm) pwForm.addEventListener('submit', function(e){
+    e.preventDefault();
+    var email = document.getElementById('trade-pw-email').value.trim();
+    var password = document.getElementById('trade-pw-pass').value;
+    var returnTo = pwForm.querySelector('input[name=return]').value;
+    if (!email || !password) { pwMsg.style.color='#b03a2e'; pwMsg.textContent = 'Email and password required.'; return; }
+    pwMsg.style.color = '';
+    pwMsg.textContent = 'Signing in...';
+    fetch('/api/trade/login-pw', {
+      method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ email: email, password: password, return: returnTo })
+    })
+      .then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })
+      .then(function(out){
+        if (!out.ok || !out.j.ok) {
+          pwMsg.style.color = '#b03a2e';
+          pwMsg.textContent = (out.j && out.j.error) || 'Sign-in failed.';
+          return;
+        }
+        pwMsg.style.color = '';
+        pwMsg.textContent = 'Signed in. Redirecting...';
+        location.href = out.j.redirect || '/';
+      })
+      .catch(function(err){ pwMsg.style.color='#b03a2e'; pwMsg.textContent = 'Network error: ' + err.message; });
+  });
+
   var form = document.getElementById('trade-login-form');
   var msg  = document.getElementById('trade-login-msg');
   if (!form) return;
@@ -3999,6 +4049,107 @@ app.post('/api/trade/login', async (req, res) => {
   });
 });
 
+// POST /api/trade/login-pw — username/password sign-in (Steve 2026-05-29:
+// "update so the un and pw work"). Env-var gated admin bypass for when email
+// isn't deliverable + admin convenience. Single-user via TRADE_ADMIN_EMAIL +
+// TRADE_ADMIN_PASS. Constant-time compare to deter timing attacks. On match:
+// upsert user row, mint the SAME JWT cookie that /trade/auth mints + admin-
+// promote via WALLCO_ADMIN_EMAILS just like the magic-link path. No bcrypt —
+// it's a single-user env-var bypass, security model equivalent to HTTP basic.
+app.post('/api/trade/login-pw', async (req, res) => {
+  const email = String((req.body && req.body.email) || '').trim().toLowerCase();
+  const password = String((req.body && req.body.password) || '');
+  const returnTo = String((req.body && req.body.return) || '/').slice(0, 500);
+  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !password) {
+    return res.status(400).json({ ok: false, error: 'Email and password required.' });
+  }
+  const ADMIN_EMAIL = String(process.env.TRADE_ADMIN_EMAIL || '').toLowerCase().trim();
+  const ADMIN_PASS  = String(process.env.TRADE_ADMIN_PASS  || '');
+  if (!ADMIN_EMAIL || !ADMIN_PASS) {
+    return res.status(503).json({ ok: false, error: 'Password sign-in not configured on this host.' });
+  }
+  // Constant-time compare. timingSafeEqual requires equal-length buffers, so
+  // check length first (length-leak is acceptable here vs returning early on
+  // mismatch which is the bigger timing leak).
+  const emailMatch = (email.length === ADMIN_EMAIL.length) &&
+    crypto.timingSafeEqual(Buffer.from(email), Buffer.from(ADMIN_EMAIL));
+  const passMatch  = (password.length === ADMIN_PASS.length) &&
+    crypto.timingSafeEqual(Buffer.from(password), Buffer.from(ADMIN_PASS));
+  if (!emailMatch || !passMatch) {
+    try {
+      psqlQuery(`INSERT INTO wallco_audit_log (user_id, is_admin, actor_label, action, payload, ip, user_agent)
+                 VALUES (NULL, FALSE, ${pgEsc(email)}, ${pgEsc('trade_login_pw_fail')},
+                         '{}'::jsonb,
+                         ${pgEsc(((req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString().split(',')[0].trim()) || '0.0.0.0')}::inet,
+                         ${pgEsc((req.headers['user-agent'] || '').toString().slice(0, 500))});`);
+    } catch(_) {}
+    // 600ms delay to deter brute-force.
+    await new Promise(r => setTimeout(r, 600));
+    return res.status(401).json({ ok: false, error: 'Invalid email or password.' });
+  }
+  // Upsert + clear any stale magic-link state + stamp last_login.
+  let row;
+  try {
+    psqlQuery(`INSERT INTO wallco_trade_users (email, last_login)
+               VALUES (${pgEsc(email)}, NOW())
+               ON CONFLICT (email) DO UPDATE
+                 SET magic_token = NULL, token_exp = NULL, last_login = NOW();`);
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (
+      SELECT id, email, name FROM wallco_trade_users WHERE email = ${pgEsc(email)} LIMIT 1
+    ) t;`);
+    row = raw ? JSON.parse(raw) : null;
+    if (!row) throw new Error('user row not found after upsert');
+  } catch (e) {
+    console.error('[trade-login-pw] upsert failed:', e.message);
+    return res.status(500).json({ ok: false, error: 'Sign-in storage error.' });
+  }
+  // Mint the same 14-day JWT + cookie as /trade/auth.
+  const jwtToken = jwt.sign(
+    { id: row.id, email: row.email, name: row.name || '' },
+    WALLCO_JWT_SECRET,
+    { algorithm: 'HS256', expiresIn: '14d' }
+  );
+  res.cookie(TRADE_COOKIE, jwtToken, {
+    httpOnly: true,
+    sameSite: 'lax',
+    secure: tradeProtoIsHttps(req),
+    maxAge: 14 * 24 * 3600 * 1000,
+    path: '/'
+  });
+  // Same admin-promote logic as /trade/auth.
+  try {
+    const ADMIN_EMAIL_LIST = (process.env.WALLCO_ADMIN_EMAILS
+      || 'steve@designerwallcoverings.com,info@designerwallcoverings.com')
+      .toLowerCase().split(',').map(s => s.trim()).filter(Boolean);
+    if (ADMIN_EMAIL_LIST.includes(email) && process.env.AUTH_DEV_SECRET) {
+      const adminJwt = jwt.sign(
+        { role: 'admin', email: row.email, name: row.name || '', source: 'trade-login-pw' },
+        process.env.AUTH_DEV_SECRET,
+        { algorithm: 'HS256', expiresIn: '14d' }
+      );
+      res.cookie('dw_auth', adminJwt, {
+        httpOnly: true,
+        sameSite: 'lax',
+        secure: tradeProtoIsHttps(req),
+        maxAge: 14 * 24 * 3600 * 1000,
+        path: '/'
+      });
+      console.log(`[trade-login-pw] admin promotion: ${row.email} -> dw_auth minted`);
+    }
+  } catch (e) {
+    console.error('[trade-login-pw] admin-promo failed (non-fatal):', e.message);
+  }
+  const safeReturn = (returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')) ? returnTo : '/';
+  try {
+    psqlQuery(`INSERT INTO wallco_audit_log (user_id, is_admin, actor_label, action, payload, ip, user_agent)
+               VALUES (${parseInt(row.id, 10)}, FALSE, ${pgEsc(row.email)}, ${pgEsc('trade_login_pw_success')},
+                       ${pgEsc(JSON.stringify({ return_to: safeReturn }))}::jsonb,
+                       ${pgEsc(((req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString().split(',')[0].trim()) || '0.0.0.0')}::inet,
+                       ${pgEsc((req.headers['user-agent'] || '').toString().slice(0, 500))});`);
+  } catch(_) {}
+  res.json({ ok: true, redirect: safeReturn });
+});
+
 // GET /trade/auth?token=... — validate token, mint JWT, set cookie, redirect.
 app.get('/trade/auth', (req, res) => {
   const token = String(req.query.token || '').trim();

← 1950b74 feat(colorways): clicking a saved colorway chip opens it as  ·  back to Wallco Ai  ·  fix(colorways): restore original chip visual after anchor sw f9d25bc →