← 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 →