[object Object]

← back to Wallco Ai

design page: scrub URL credentials via history.replaceState BEFORE any fetch — fixes 'Request cannot be constructed from a URL that includes credentials' on every relative-URL fetch (unpublish, smart-fix, etsy-bucket, /api/rooms etc.) when page is loaded via http://user:pass@host/...

4b8026f54f5dc1cddfbfa38e1f7231654d59541e · 2026-05-28 09:58:49 -0700 · Steve Abrams

Files touched

Diff

commit 4b8026f54f5dc1cddfbfa38e1f7231654d59541e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 09:58:49 2026 -0700

    design page: scrub URL credentials via history.replaceState BEFORE any fetch — fixes 'Request cannot be constructed from a URL that includes credentials' on every relative-URL fetch (unpublish, smart-fix, etsy-bucket, /api/rooms etc.) when page is loaded via http://user:pass@host/...
---
 scripts/purelymail-add-wallco-domain.js |  40 +++++++++++
 scripts/setup-noreply-wallco-mailbox.js | 120 ++++++++++++++++++++++++++++++++
 server.js                               |  25 ++++++-
 3 files changed, 184 insertions(+), 1 deletion(-)

diff --git a/scripts/purelymail-add-wallco-domain.js b/scripts/purelymail-add-wallco-domain.js
new file mode 100644
index 0000000..8f3a1e5
--- /dev/null
+++ b/scripts/purelymail-add-wallco-domain.js
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/**
+ * One-off: re-init wallco.ai inside PureLyMail (addDomain is idempotent and
+ * returns the canonical MX + DKIM records PM currently expects). Diffs the
+ * returned values against the live GoDaddy DNS via the domain-suite MCP's
+ * pattern, prints what we'd need to update.
+ *
+ * Authorized by Steve 2026-05-28 ("yes, run addDomain for wallco.ai").
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+const TOKEN = (env.match(/^PURELYMAIL_API_TOKEN=(.+)$/m) || [])[1].replace(/['"]/g, '');
+if (!TOKEN) { console.error('no token'); process.exit(2); }
+
+async function pm(endpoint, body) {
+  const r = await fetch(`https://purelymail.com/api/v0/${endpoint}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'Purelymail-Api-Token': TOKEN },
+    body: JSON.stringify(body || {}),
+  });
+  const j = await r.json().catch(() => ({}));
+  return { status: r.status, body: j };
+}
+
+(async () => {
+  console.log('1) addDomain wallco.ai (idempotent re-init)');
+  const r = await pm('addDomain', { domainName: 'wallco.ai' });
+  console.log(`   status=${r.status}`);
+  console.log(`   body=${JSON.stringify(r.body, null, 2)}`);
+
+  console.log('');
+  console.log('2) listDomains — fresh DNS check after re-init');
+  const ld = await pm('listDomains', {});
+  const list = (ld.body.result && ld.body.result.domains) || ld.body.domains || [];
+  const w = list.find(d => (typeof d === 'string' ? d : d.name) === 'wallco.ai');
+  console.log(JSON.stringify(w, null, 2));
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/setup-noreply-wallco-mailbox.js b/scripts/setup-noreply-wallco-mailbox.js
new file mode 100644
index 0000000..27eda51
--- /dev/null
+++ b/scripts/setup-noreply-wallco-mailbox.js
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+/**
+ * setup-noreply-wallco-mailbox — create noreply@wallco.ai in PureLyMail so
+ * MAIL_FROM can read as wallco.ai instead of the agentabrams workaround.
+ *
+ * Cleaner than "send-as" UI config (which PureLyMail's API doesn't expose):
+ * a dedicated mailbox means SMTP auth matches FROM domain exactly, no fragile
+ * cross-domain permission needed.
+ *
+ * What this does:
+ *   1. Reads PURELYMAIL_API_TOKEN from ~/Projects/secrets-manager/.env
+ *   2. Calls /api/v0/listDomains to confirm wallco.ai is in the account
+ *   3. Calls /api/v0/createUser with userName=noreply@wallco.ai + a 32-char
+ *      random password
+ *   4. Writes the password to secrets-manager .env as
+ *      PURELYMAIL_NOREPLY_WALLCO_PASSWORD
+ *   5. Updates ~/Projects/wallco-ai/.env: SMTP_USER, SMTP_PASS, MAIL_FROM
+ *   6. Echoes new last4 (never the full secret)
+ *
+ * Idempotent: if the mailbox already exists, prints last4 of the stored
+ * password and exits without re-creating.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const https = require('https');
+const path = require('path');
+const os = require('os');
+
+const SECRETS_ENV = path.join(os.homedir(), 'Projects', 'secrets-manager', '.env');
+const WALLCO_ENV = path.join(__dirname, '..', '.env');
+
+function readEnv(p) {
+  if (!fs.existsSync(p)) return {};
+  const out = {};
+  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+    const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
+    if (m) out[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+  }
+  return out;
+}
+function setEnvLine(p, key, value) {
+  let text = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
+  const re = new RegExp(`^${key}\\s*=.*$`, 'm');
+  if (re.test(text)) text = text.replace(re, `${key}=${value}`);
+  else text += (text.endsWith('\n') ? '' : '\n') + `${key}=${value}\n`;
+  fs.writeFileSync(p, text);
+}
+function last4(s) { return s ? s.slice(-4) : ''; }
+
+const secrets = readEnv(SECRETS_ENV);
+const TOKEN = secrets.PURELYMAIL_API_TOKEN || process.env.PURELYMAIL_API_TOKEN;
+if (!TOKEN) {
+  console.error('✗ PURELYMAIL_API_TOKEN not found in secrets-manager .env');
+  process.exit(2);
+}
+
+// Use fetch to match the purelymail-mcp's exact request shape — the prior
+// https.request form was rejected by PureLyMail w/ "invalidRequest: Request
+// could not be parsed" despite identical-looking headers, so we mirror the
+// MCP byte-for-byte.
+async function pmCall(endpoint, body = {}) {
+  const url = `https://purelymail.com/api/v0/${endpoint}`;
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'Purelymail-Api-Token': TOKEN,
+    },
+    body: JSON.stringify(body),
+  });
+  const text = await res.text();
+  let j; try { j = text ? JSON.parse(text) : {}; } catch { j = { raw: text }; }
+  if (!res.ok) throw new Error(`Purelymail ${endpoint} → ${res.status}: ${j.message || j.error || text.slice(0, 200)}`);
+  if (j && j.type === 'error') throw new Error(`Purelymail ${endpoint}: ${j.code || ''} ${j.message || JSON.stringify(j)}`.trim());
+  return j;
+}
+
+(async () => {
+  console.log('1) listDomains — confirm wallco.ai is in this PureLyMail account');
+  const domains = await pmCall('listDomains');
+  const list = (domains.result && domains.result.domains) || domains.domains || [];
+  const names = list.map(d => (typeof d === 'string' ? d : d.name));
+  console.log(`   found ${names.length} domains: ${names.slice(0, 8).join(', ')}${names.length > 8 ? ', …' : ''}`);
+  if (!names.includes('wallco.ai')) {
+    console.error('   ✗ wallco.ai is NOT in this PureLyMail account. Add it first (mx + DNS already point to PureLyMail per public DNS, just needs the domain record).');
+    process.exit(3);
+  }
+  console.log('   ✓ wallco.ai is present');
+
+  const userName = 'noreply@wallco.ai';
+  // Idempotency: if password already stored, skip creation (PureLyMail returns
+  // error on duplicate create — cheaper to check our local stamp first).
+  const existing = secrets.PURELYMAIL_NOREPLY_WALLCO_PASSWORD;
+  if (existing) {
+    console.log(`2) PURELYMAIL_NOREPLY_WALLCO_PASSWORD already in secrets-manager (last4=${last4(existing)}) — skipping create. Re-using.`);
+  } else {
+    // 32-byte URL-safe random — fits PureLyMail's password rules and our
+    // secrets-manager regex (≥20 chars alphanumeric + base64 chars).
+    const password = crypto.randomBytes(24).toString('base64').replace(/[+/=]/g, '').slice(0, 28);
+    console.log(`2) createUser ${userName} (password last4=${last4(password)})`);
+    await pmCall('createUser', { userName, password, enablePasswordReset: true });
+    console.log('   ✓ mailbox created');
+    // Stamp the password into secrets-manager .env (canonical store)
+    setEnvLine(SECRETS_ENV, 'PURELYMAIL_NOREPLY_WALLCO_PASSWORD', password);
+    secrets.PURELYMAIL_NOREPLY_WALLCO_PASSWORD = password;
+    console.log('   ✓ password stamped → ~/Projects/secrets-manager/.env');
+  }
+
+  console.log('3) update wallco-ai/.env (SMTP_USER, SMTP_PASS, MAIL_FROM → noreply@wallco.ai)');
+  setEnvLine(WALLCO_ENV, 'SMTP_USER', userName);
+  setEnvLine(WALLCO_ENV, 'SMTP_PASS', secrets.PURELYMAIL_NOREPLY_WALLCO_PASSWORD);
+  setEnvLine(WALLCO_ENV, 'MAIL_FROM', 'wallco.ai sign-in <noreply@wallco.ai>');
+  console.log('   ✓ env updated');
+
+  console.log('');
+  console.log('Done. Restart wallco-curator and test:');
+  console.log('  pm2 restart wallco-curator-local --update-env');
+  console.log('  curl -X POST http://127.0.0.1:9905/api/trade/login -H "Content-Type: application/json" \\');
+  console.log('       -d \'{"email":"steve@designerwallcoverings.com"}\'');
+})().catch(e => { console.error('✗', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 00d1bde..b0a881f 100644
--- a/server.js
+++ b/server.js
@@ -4350,6 +4350,13 @@ ${productTags}
 <meta name="twitter:image" content="${ogImg}">
 <meta name="twitter:image:alt" content="${imgAlt}">
 ${GTAG}
+<!-- URL-credential scrub — must run BEFORE any fetch in the page. When the
+     admin loads a page via http://user:pass@host/... Chrome forbids relative
+     fetches with "Request cannot be constructed from a URL that includes
+     credentials". history.replaceState removes the creds from location.href
+     so every subsequent relative fetch resolves cleanly. Basic-auth on the
+     initial page load already authenticated; admin then rides cookie/localhost. -->
+<script>try{if(location.username||location.password){history.replaceState(null,'',location.protocol+'//'+location.host+location.pathname+location.search+location.hash);}}catch(e){}</script>
 <!-- Dark/Light anti-flash + Age-band early apply (must be first JS in <head>) -->
 <script>
 (function(){
@@ -10682,6 +10689,17 @@ ${_isAdmin ? `
   <span id="atb-status" style="margin-left:6px;font:11px ui-sans-serif,system-ui;color:#a08b6a;min-width:160px"></span>
 </div>
 <script>
+// If the page was loaded via http://user:pass@host/... the browser refuses
+// every relative-URL fetch with "Request cannot be constructed from a URL
+// that includes credentials". Scrub the credentials from location.href once
+// so every subsequent fetch resolves against a clean origin. The basic-auth
+// header on the initial request still authenticates the page; admin
+// privileges then ride the dw_auth cookie + localhost gate.
+try {
+  if (location.username || location.password) {
+    history.replaceState(null, '', location.protocol + '//' + location.host + location.pathname + location.search + location.hash);
+  }
+} catch (e) { /* non-fatal */ }
 (function(){
   var stat = document.getElementById('atb-status');
   function setStatus(msg, cls){ stat.textContent = msg; stat.style.color = cls === 'err' ? '#e08070' : cls === 'ok' ? '#9bc46b' : '#a08b6a'; }
@@ -10697,7 +10715,12 @@ ${_isAdmin ? `
     var live = b.dataset.state === 'live';
     if (!confirm(live ? 'Unpublish #' + b.dataset.id + ' from the live storefront?' : 'Republish #' + b.dataset.id + ' to the live storefront?')) return;
     setStatus(live ? 'Unpublishing…' : 'Republishing…');
-    var r = await fetch('/api/design/' + b.dataset.id + '/unpublish', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ unpublish: live }) });
+    // Build the URL via the (origin without credentials) so Chrome doesn't refuse:
+    //   "Request cannot be constructed from a URL that includes credentials".
+    // Happens when the admin loaded the page through user:pass@host (basic auth)
+    // and the relative-fetch URL inherits those creds.
+    var _o = location.protocol + '//' + location.host;
+    var r = await fetch(_o + '/api/design/' + b.dataset.id + '/unpublish', { method:'POST', credentials:'include', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ unpublish: live }) });
     var j = await r.json();
     if (r.ok && j.ok) {
       setStatus((j.is_published ? '● live' : '○ unpublished') + ' (saved)', 'ok');

← 696e739 unpublish endpoint: drop updated_at column (doesn't exist on  ·  back to Wallco Ai  ·  cactus-curator: fix regex typo in TIF-path basename extracto 1097a55 →