← 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
A scripts/purelymail-add-wallco-domain.jsA scripts/setup-noreply-wallco-mailbox.jsM server.js
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 →