[object Object]

← back to Marketing Command Center

MCC should-fix sweep: unify Kravet block (lib/kravet.js, server+client single source, caption/name-aware, button disabled on block), add CA §17529.5 subject/from screening, origin-absolute fetches in assets+CC panels, align OAUTH_REDIRECT_BASE default, add 5th LinkedIn template

9968bde2514a1d5e774ddd950411c81763234499 · 2026-06-14 19:46:21 -0700 · Steve Abrams

Files touched

Diff

commit 9968bde2514a1d5e774ddd950411c81763234499
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 14 19:46:21 2026 -0700

    MCC should-fix sweep: unify Kravet block (lib/kravet.js, server+client single source, caption/name-aware, button disabled on block), add CA §17529.5 subject/from screening, origin-absolute fetches in assets+CC panels, align OAUTH_REDIRECT_BASE default, add 5th LinkedIn template
---
 lib/kravet.js                     | 39 ++++++++++++++++++++++
 modules/assets/index.js           | 15 +++++++--
 modules/channels/index.js         |  5 +--
 modules/constant-contact/index.js | 56 ++++++++++++++++++++++++++++----
 modules/linkedin/index.js         |  1 +
 public/panels/assets.js           | 68 +++++++++++++++++++++++++++------------
 public/panels/constant-contact.js |  6 ++--
 7 files changed, 156 insertions(+), 34 deletions(-)

diff --git a/lib/kravet.js b/lib/kravet.js
new file mode 100644
index 0000000..c5286b6
--- /dev/null
+++ b/lib/kravet.js
@@ -0,0 +1,39 @@
+'use strict';
+// Canonical Kravet-family brand block — single source of truth.
+// Reposting brand-controlled (Kravet-umbrella) imagery under the DW account is
+// hard-blocked per Steve's standing rule. Both the server repost gate AND the
+// client repost modal read this list (the client fetches it via
+// GET /api/assets/repost-policy) so the two can never drift apart again.
+//
+// Keep this list in sync with the Kravet-family pricing rule in MEMORY.md
+// (kravet-lines-price-at-map).
+const KRAVET_BRANDS = [
+  'kravet', 'lee jofa', 'lee jofa modern', 'groundworks', 'brunschwig',
+  'cole & son', 'cole and son', 'gp & j baker', 'gp&j baker', 'g p & j baker',
+  'colefax', 'clarke & clarke', 'clarke and clarke', 'clarke', 'mulberry',
+  'threads', 'baker lifestyle', 'andrew martin', 'nicolette mayer', 'aerin',
+  'barclay butera', 'thom filicia',
+];
+
+// One regex, built from the list. Spaces are matched loosely (any whitespace,
+// optional surrounding) and "&"/"and" are treated as interchangeable so handles
+// like @coleandson, @cole_and_son, "Cole & Son" all match.
+// Tokens are joined by ANY separator (space, _, -, .) or none, so handle forms
+// like "@cole_and_son", "@lee-jofa", "Cole & Son" and "coleandson" all match.
+const KRAVET_RE = new RegExp(
+  KRAVET_BRANDS
+    .map(b => b
+      .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')  // escape regex metachars
+      .replace(/\s*(&|and)\s*/gi, '[\\s_.-]*(?:&|and)?[\\s_.-]*') // & / and / nothing
+      .replace(/\s+/g, '[\\s_.-]*'))            // loose internal separators
+    .join('|'),
+  'i',
+);
+
+// True if ANY of the supplied strings (handle, caption, asset name, tags…)
+// reference a brand-controlled line.
+function isKravet(...strings) {
+  return strings.some(s => s && KRAVET_RE.test(String(s)));
+}
+
+module.exports = { KRAVET_BRANDS, KRAVET_RE, isKravet };
diff --git a/modules/assets/index.js b/modules/assets/index.js
index 210b277..528968f 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -21,6 +21,7 @@ const fs = require('fs');
 const path = require('path');
 const express = require('express');
 const { execFileSync } = require('child_process');
+const { KRAVET_RE, isKravet } = require('../../lib/kravet.js'); // canonical brand block
 
 // Google Drive (info@ account) source — rclone remote is the configured `gdrive:`.
 const GDRIVE_REMOTE = process.env.GDRIVE_REMOTE || 'gdrive:';
@@ -393,10 +394,13 @@ module.exports = {
     // hard code-gates and STAGES a draft — it never publishes (publish needs the
     // settlement image gate + Steve's approval + a connected Meta token).
     const REPOSTS = path.join(DATA_DIR, 'reposts.json');
-    const KRAVET_RE = /kravet|lee\s*jofa|groundworks|brunschwig|cole.*son|gp.*baker|colefax|clarke|mulberry|baker lifestyle|andrew martin|nicolette mayer|barclay butera|thom filicia/i;
     const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
     function readReposts() { try { return JSON.parse(fs.readFileSync(REPOSTS, 'utf8')); } catch { return []; } }
 
+    // Expose the canonical brand-block pattern so the client modal enforces the
+    // exact same rule (no client/server regex drift).
+    router.get('/repost-policy', (_req, res) => res.json({ kravetPattern: KRAVET_RE.source, kravetFlags: KRAVET_RE.flags }));
+
     router.post('/repost', (req, res) => {
       const { id, caption, rightsConfirmed, sourceHandle, paid } = req.body || {};
       const store = readStore();
@@ -407,8 +411,13 @@ module.exports = {
       // resolve credit handle (explicit, else an @tag on the asset)
       let handle = (sourceHandle || (asset.tags || []).find(t => /^@/.test(t)) || '').trim();
       if (handle && !handle.startsWith('@')) handle = '@' + handle;
-      // GATE 2 — brand-controlled source hard-block (Kravet family etc.)
-      if (handle && KRAVET_RE.test(handle)) return res.status(403).json({ gate: 'blocked-vendor', error: `${handle} is brand-controlled (Kravet family) — reposting is blocked. Use a licensed brand asset instead.` });
+      // GATE 2 — brand-controlled source hard-block (Kravet family etc.).
+      // Checks the handle AND the caption, asset name, and tags — a Kravet
+      // product can't slip through just because the crediting handle isn't a
+      // Kravet handle (e.g. a designer reposting a Cole & Son install).
+      if (isKravet(handle, caption, asset.name, ...(asset.tags || []))) {
+        return res.status(403).json({ gate: 'blocked-vendor', error: `Brand-controlled (Kravet family) content detected — reposting is blocked. Use a licensed brand asset instead.` });
+      }
       // GATE 3 — auto-credit required
       if (!handle) return res.status(400).json({ gate: 'credit', error: 'No source @handle to credit — add the source handle (auto-credit is mandatory).' });
       // build caption: "Wallpaper" word-ban + ensure credit + #ad if paid (GATE 4)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index f46f6d9..1397cbb 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -22,8 +22,9 @@ const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
 const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
 const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
 // Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
-// in each app console. Defaults to the live host; override with OAUTH_REDIRECT_BASE.
-const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.designerwallcoverings.com';
+// in each app console. Defaults to the canonical live host (matches .env's
+// OAUTH_REDIRECT_BASE); override with OAUTH_REDIRECT_BASE.
+const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.agentabrams.com';
 const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;
 
 // ── Self-serve setup (the Activate page) ──────────────────────────────────────
diff --git a/modules/constant-contact/index.js b/modules/constant-contact/index.js
index 4721413..5640704 100644
--- a/modules/constant-contact/index.js
+++ b/modules/constant-contact/index.js
@@ -112,6 +112,30 @@ function canSpamCheck(html = '') {
   return { ok: missing.length === 0, hasUnsub, hasAddress, missing };
 }
 
+// ── California §17529.5 / CAN-SPAM deceptive-subject + sender screen ───────────
+// §17529.5 makes it unlawful to send commercial email with a misleading subject
+// line or falsified/inaccurate header ("From") info. HARD flags block the send
+// (clearly deceptive); SOFT flags are advisory (spammy but not per-se unlawful).
+function subjectScreen(subject = '') {
+  const s = String(subject).trim();
+  const hard = [], soft = [];
+  if (!s) hard.push('empty subject line');
+  const letters = s.replace(/[^a-z]/gi, '');
+  if (letters.length >= 6 && letters === letters.toUpperCase()) hard.push('subject is ALL-CAPS (deceptive / shouting)');
+  if (/^\s*(re|fw|fwd)\s*:/i.test(s)) hard.push('fake reply/forward prefix (RE:/FW:) — deceptive header');
+  if (/\b(you(’|')?ve? (won|been selected)|congratulations[, ]+you|claim your (free )?(prize|gift|reward)|you are (a|the) winner)\b/i.test(s)) hard.push('deceptive prize/winner claim');
+  if (/!{2,}|\?!|!\?/.test(s)) soft.push('excessive punctuation');
+  if (/\b(free|act now|urgent|limited time|last chance|risk[\s-]?free|100%|guarantee(d)?|click here|buy now|order now)\b/i.test(s)) soft.push('spam-trigger wording');
+  if (/(\$\s?\d|\d+%\s*off)/.test(s)) soft.push('price/discount in subject line');
+  if (/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(s)) soft.push('emoji in subject line');
+  return { ok: hard.length === 0, hard, soft, flags: [...hard, ...soft] };
+}
+function fromScreen(fromEmail = '') {
+  const e = String(fromEmail).trim();
+  const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e);
+  return { ok: valid, fromEmail: e, reason: valid ? null : 'From email is missing or malformed — accurate sender info is required (CAN-SPAM / §17529.5)' };
+}
+
 // ── MOCK DATA (realistic shapes, used whenever no access token) ────────────────
 const MOCK = {
   campaigns: [
@@ -258,13 +282,15 @@ function mount(router) {
     if (missing.length) return res.status(400).json({ error: `missing fields: ${missing.join(', ')}` });
 
     const compliance = canSpamCheck(html);
+    const subjectScan = subjectScreen(subject);
+    const fromScan = fromScreen(from);
 
     if (!live()) {
       const id = `mock-draft-${Date.now().toString(36)}`;
       return res.json({
         mock: true, ok: true, draft_id: id, status: 'DRAFT',
         message: `Draft “${name}” saved (mock). Not sent.`,
-        compliance,
+        compliance, subjectScan, fromScan,
       });
     }
 
@@ -286,7 +312,7 @@ function mount(router) {
       draft_id: created.campaign_id || created.id,
       status: created.current_status || 'DRAFT',
       message: `Draft “${name}” created. Not sent.`,
-      compliance,
+      compliance, subjectScan, fromScan,
     });
   }));
 
@@ -294,7 +320,7 @@ function mount(router) {
   // unless CAN-SPAM fields are present. NEVER sends without confirm && !dryRun.
   router.post('/schedule', guard(async (req, res) => {
     const b = req.body || {};
-    const { draft_id, scheduled_time, html } = b;
+    const { draft_id, scheduled_time, html, subject, from } = b;
     const confirm = b.confirm === true;
     const dryRun = b.dryRun !== false; // default true → dry-run unless explicitly false
 
@@ -302,11 +328,26 @@ function mount(router) {
 
     // CAN-SPAM gate first — block before anything else if non-compliant.
     const compliance = canSpamCheck(html || '');
+    const subjectScan = subjectScreen(subject || '');
+    const fromScan = fromScreen(from || '');
     if (!compliance.ok) {
       return res.status(422).json({
         ok: false, blocked: true, reason: 'CAN-SPAM',
         message: `Refusing to schedule: campaign HTML is missing ${compliance.missing.join(' + ')}. ${brand.compliance}`,
-        compliance,
+        compliance, subjectScan, fromScan,
+      });
+    }
+
+    // California §17529.5 gate — block on a deceptive subject or bad "From".
+    // (Only enforced when subject/from are supplied; soft flags are advisory.)
+    const ca = [];
+    if (subject != null && !subjectScan.ok) ca.push(...subjectScan.hard);
+    if (from != null && !fromScan.ok) ca.push(fromScan.reason);
+    if (ca.length) {
+      return res.status(422).json({
+        ok: false, blocked: true, reason: 'CA-17529.5',
+        message: `Refusing to schedule (CA §17529.5): ${ca.join('; ')}. Fix the subject line / From address and re-submit.`,
+        compliance, subjectScan, fromScan,
       });
     }
 
@@ -321,11 +362,12 @@ function mount(router) {
 
     // Dry-run (default): validate + echo what WOULD happen. No outbound call.
     if (dryRun) {
+      const softWarn = subjectScan.soft.length ? ` Subject advisories: ${subjectScan.soft.join(', ')}.` : '';
       return res.json({
         ok: true, dryRun: true, wouldSchedule: true,
         draft_id, scheduled_time: scheduled_time || 'now',
-        message: `DRY RUN — would schedule draft ${draft_id} for ${scheduled_time || 'immediate'} send. Nothing was sent. Re-submit with dryRun:false to commit.`,
-        compliance,
+        message: `DRY RUN — would schedule draft ${draft_id} for ${scheduled_time || 'immediate'} send. Nothing was sent. Re-submit with dryRun:false to commit.${softWarn}`,
+        compliance, subjectScan, fromScan,
       });
     }
 
@@ -406,4 +448,6 @@ module.exports = {
   mount,
   // exported for any internal reuse / testing
   _canSpamCheck: canSpamCheck,
+  _subjectScreen: subjectScreen,
+  _fromScreen: fromScreen,
 };
diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
index db386e3..6e7228c 100644
--- a/modules/linkedin/index.js
+++ b/modules/linkedin/index.js
@@ -26,6 +26,7 @@ const TEMPLATES = [
   { id: 'insight', name: 'Trade insight', body: 'One thing most {audience} get wrong about {topic}:\n\n{insight}\n\nHere’s how we approach it at Designer Wallcoverings.' },
   { id: 'feature', name: 'Product feature', body: 'Material spotlight: {product}.\n\nWhy it works: {benefit1}; {benefit2}; {benefit3}.\n\nSpecifying for a project? Let’s talk.' },
   { id: 'lesson', name: 'Lesson from an install', body: 'A lesson from a recent install:\n\n{lesson}\n\nSmall detail, big difference.' },
+  { id: 'testimonial', name: 'Specifier testimonial', body: '“{quote}”\n\nThat’s from {role} on {project}, after specifying {product}.\n\nNothing means more than a designer trusting us on a project. Thank you, {name}.' },
 ];
 
 // Connected only when a token + an author/org URN are set (Phase 2).
diff --git a/public/panels/assets.js b/public/panels/assets.js
index c8b6714..fc6509c 100644
--- a/public/panels/assets.js
+++ b/public/panels/assets.js
@@ -6,6 +6,19 @@ window.MCC_PANELS['assets'] = {
   init(root) {
     const $ = s => root.querySelector(s);
     const $$ = s => root.querySelectorAll(s);
+    // Origin-absolute fetch — avoids the "URL includes credentials" throw when
+    // the page is opened with embedded basic-auth creds (matches channels/linkedin).
+    const ORIGIN = location.origin;
+    const fetchO = (u, opts) => fetch((typeof u === 'string' && u[0] === '/') ? ORIGIN + u : u, Object.assign({ credentials: 'same-origin' }, opts));
+
+    // Canonical Kravet-family block — fetched from the server (/repost-policy) so
+    // the client modal enforces the EXACT same rule the server gate does. Baked
+    // fallback mirrors lib/kravet.js in case the policy fetch fails.
+    let KRAVET_RE = /kravet|lee[\s_.-]*jofa|groundworks|brunschwig|cole[\s_.-]*(?:&|and)?[\s_.-]*son|gp[\s_.-]*(?:&|and)?[\s_.-]*j?[\s_.-]*baker|colefax|clarke|mulberry|threads|baker[\s_.-]*lifestyle|andrew[\s_.-]*martin|nicolette[\s_.-]*mayer|aerin|barclay[\s_.-]*butera|thom[\s_.-]*filicia/i;
+    fetchO('/api/assets/repost-policy').then(r => r.json())
+      .then(p => { if (p && p.kravetPattern) KRAVET_RE = new RegExp(p.kravetPattern, p.kravetFlags || 'i'); })
+      .catch(() => { /* keep baked fallback */ });
+    const isKravetClient = (...ss) => ss.some(s => s && KRAVET_RE.test(String(s)));
 
     const libGrid   = $('#as-lib-grid');
     const libEmpty  = $('#as-lib-empty');
@@ -69,7 +82,7 @@ window.MCC_PANELS['assets'] = {
       libGrid.querySelectorAll('[data-repost]').forEach(b => b.addEventListener('click', () => openRepost(b.dataset.repost)));
     }
     async function loadLibrary() {
-      try { allAssets = (await (await fetch('/api/assets/list')).json()).assets || []; }
+      try { allAssets = (await (await fetchO('/api/assets/list')).json()).assets || []; }
       catch (e) { setStatus('Could not load library: ' + e.message, true); }
       renderLibrary();
     }
@@ -106,7 +119,7 @@ window.MCC_PANELS['assets'] = {
 
     async function del(id) {
       try {
-        const r = await fetch('/api/assets/' + encodeURIComponent(id), { method: 'DELETE' });
+        const r = await fetchO('/api/assets/' + encodeURIComponent(id), { method: 'DELETE' });
         if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
         loadLibrary();
       } catch (e) { setStatus('Delete failed: ' + e.message, true); }
@@ -129,7 +142,7 @@ window.MCC_PANELS['assets'] = {
         setStatus(`Uploading ${f.name}… (${ok + 1}/${files.length})`);
         try {
           const dataUrl = await readAsDataURL(f);
-          const r = await fetch('/api/assets/upload', {
+          const r = await fetchO('/api/assets/upload', {
             method: 'POST', headers: { 'Content-Type': 'application/json' },
             body: JSON.stringify({ name: f.name.replace(/\.[^.]+$/, ''), dataUrl }),
           });
@@ -152,7 +165,7 @@ window.MCC_PANELS['assets'] = {
       const name = $('#as-url-name').value.trim();
       if (!url) { setStatus('Paste an image URL first.', true); return; }
       try {
-        const r = await fetch('/api/assets/add-url', {
+        const r = await fetchO('/api/assets/add-url', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ url, name }),
         });
@@ -172,7 +185,7 @@ window.MCC_PANELS['assets'] = {
       catStatus.textContent = 'Searching the live DW catalog…';
       catGrid.innerHTML = '';
       try {
-        const r = await fetch('/api/assets/catalog?limit=80&q=' + encodeURIComponent(q));
+        const r = await fetchO('/api/assets/catalog?limit=80&q=' + encodeURIComponent(q));
         const d = await r.json();
         if (!r.ok) throw new Error(d.error || r.status);
         catStatus.textContent = `${d.products.length} of ${d.total} match${d.total === 1 ? '' : 'es'}${d.cached ? ' · cached' : ''}. Click an image to save it to your library.`;
@@ -190,7 +203,7 @@ window.MCC_PANELS['assets'] = {
       const url = card.dataset.url, name = card.dataset.name;
       card.style.outline = '2px solid var(--gold)';
       try {
-        const r = await fetch('/api/assets/save-catalog', {
+        const r = await fetchO('/api/assets/save-catalog', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ url, name }),
         });
@@ -210,7 +223,7 @@ window.MCC_PANELS['assets'] = {
       if (!path) { setStatus('Enter a Drive folder path first.', true); return; }
       setStatus(`Importing images from “${path}”… (this can take a minute)`);
       try {
-        const r = await fetch('/api/assets/import-gdrive', {
+        const r = await fetchO('/api/assets/import-gdrive', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ path, limit }),
         });
@@ -225,7 +238,7 @@ window.MCC_PANELS['assets'] = {
       const shareLink = $('#as-gd-link').value.trim();
       if (!shareLink) { setStatus('Paste a Drive file share-link first.', true); return; }
       try {
-        const r = await fetch('/api/assets/add-drive', {
+        const r = await fetchO('/api/assets/add-drive', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ shareLink }),
         });
@@ -242,7 +255,7 @@ window.MCC_PANELS['assets'] = {
       if (!html) { setStatus('Paste campaign HTML first.', true); return; }
       setStatus('Harvesting image URLs…');
       try {
-        const r = await fetch('/api/assets/harvest', {
+        const r = await fetchO('/api/assets/harvest', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ html }),
         });
@@ -259,7 +272,7 @@ window.MCC_PANELS['assets'] = {
       if (!url) { setStatus('Paste an Instagram post URL first.', true); return; }
       setStatus('Fetching Instagram image…');
       try {
-        const r = await fetch('/api/assets/import-ig-url', {
+        const r = await fetchO('/api/assets/import-ig-url', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ url }),
         });
@@ -270,18 +283,17 @@ window.MCC_PANELS['assets'] = {
         loadLibrary();
       } catch (e) { setStatus('Could not grab IG image: ' + e.message, true); }
     });
-    // ── Vendor IG roster with regram status ─────────────────────────────────────
-    const KRAVET = /kravet|lee jofa|groundworks|brunschwig|cole\s*&\s*son|gp\s*&?\s*j?\s*baker|colefax|clarke|mulberry|threads|baker lifestyle|andrew martin|nicolette mayer|aerin|barclay butera|thom filicia/i;
+    // ── Vendor IG roster with regram status (uses the canonical KRAVET_RE) ──────
     function regramStatus(v) {
       if (v.vendorCode === 'dw' || /our account/i.test(v.note || '')) return 'allow';
-      if (KRAVET.test(v.brand || '') || KRAVET.test(v.vendorCode || '')) return 'block';
+      if (isKravetClient(v.brand, v.vendorCode)) return 'block';
       return 'review';
     }
     const RG_COLOR = { allow: '#3a6b3a', review: '#8a6a2a', block: '#9a5a5a' };
     async function loadVendorIG() {
       const box = $('#as-ig-vendors'); if (!box) return;
       let accounts = [];
-      try { accounts = (await (await fetch('/api/vendors/accounts')).json()).accounts || []; }
+      try { accounts = (await (await fetchO('/api/vendors/accounts')).json()).accounts || []; }
       catch { box.innerHTML = '<div class="muted" style="padding:12px">Could not load vendor roster.</div>'; return; }
       const withIG = accounts.filter(a => a.hasIG && a.url);
       $('#as-ig-vcount').textContent = withIG.length;
@@ -308,7 +320,7 @@ window.MCC_PANELS['assets'] = {
     $('#as-cc-go').addEventListener('click', async () => {
       setStatus('Importing from Constant Contact…');
       try {
-        const r = await fetch('/api/assets/import-cc-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+        const r = await fetchO('/api/assets/import-cc-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
         const d = await r.json();
         if (!r.ok) { setStatus((d.needsToken ? '🔒 ' : '✗ ') + (d.error || r.status), true); return; }
         setStatus(`Imported ${d.added} image(s) from Constant Contact.`);
@@ -317,7 +329,6 @@ window.MCC_PANELS['assets'] = {
     });
 
     // ── Repost (gated, stage-only) modal ────────────────────────────────────────
-    const KRAVET_RE = /kravet|lee\s*jofa|groundworks|brunschwig|cole.*son|gp.*baker|colefax|clarke|mulberry|baker lifestyle|andrew martin|nicolette mayer|barclay butera|thom filicia/i;
     const deWP = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
     const modal = document.createElement('div');
     modal.id = 'as-repost-modal';
@@ -357,6 +368,23 @@ window.MCC_PANELS['assets'] = {
     rp('#rp-x').onclick = closeRepost;
     modal.addEventListener('click', e => { if (e.target === modal) closeRepost(); });
     let rpAsset = null;
+    // Re-evaluate the brand-block on every relevant change. Checks handle + caption
+    // + asset name + tags (same surface the server gate enforces) and DISABLES the
+    // Stage button when blocked — the block is enforced, not just advisory.
+    function recheckBlock() {
+      if (!rpAsset) return;
+      const handle = rp('#rp-handle').value.trim();
+      const caption = rp('#rp-caption').value;
+      const blocked = isKravetClient(handle, caption, rpAsset.name, ...(rpAsset.tags || []));
+      const stage = rp('#rp-stage');
+      stage.disabled = blocked;
+      stage.style.opacity = blocked ? '.5' : '';
+      stage.style.cursor = blocked ? 'not-allowed' : '';
+      stage.title = blocked ? 'Blocked — brand-controlled (Kravet family) content cannot be reposted' : '';
+      rp('#rp-warn').innerHTML = blocked
+        ? `<span style="color:#9a5a5a;font-weight:600;">⚠ Brand-controlled (Kravet family) — reposting is blocked.</span>`
+        : ((rpAsset.tags || []).includes('uncleared') ? 'Pulled image — confirm rights before reposting.' : '');
+    }
     function openRepost(id) {
       rpAsset = allAssets.find(a => a.id === id); if (!rpAsset) return;
       const handle = (rpAsset.tags || []).find(t => /^@/.test(t)) || '';
@@ -366,11 +394,11 @@ window.MCC_PANELS['assets'] = {
       rp('#rp-caption').value = deWP(`${rpAsset.name || ''} — available at Designer Wallcoverings.`);
       rp('#rp-rights').checked = false; rp('#rp-paid').checked = false;
       rp('#rp-msg').textContent = '';
-      rp('#rp-warn').innerHTML = handle && KRAVET_RE.test(handle)
-        ? `<span style="color:#9a5a5a;font-weight:600;">⚠ ${esc(handle)} is brand-controlled (Kravet family) — reposting is blocked.</span>`
-        : ((rpAsset.tags || []).includes('uncleared') ? 'Pulled image — confirm rights before reposting.' : '');
+      recheckBlock();
       modal.style.display = 'flex';
     }
+    rp('#rp-handle').addEventListener('input', recheckBlock);
+    rp('#rp-caption').addEventListener('input', recheckBlock);
     rp('#rp-stage').onclick = async () => {
       const body = {
         id: rpAsset && rpAsset.id,
@@ -381,7 +409,7 @@ window.MCC_PANELS['assets'] = {
       };
       rp('#rp-msg').textContent = 'Staging…';
       try {
-        const r = await fetch('/api/assets/repost', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+        const r = await fetchO('/api/assets/repost', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
         const d = await r.json();
         if (!r.ok) { rp('#rp-msg').textContent = '✗ ' + (d.error || r.status); rp('#rp-msg').style.color = '#c0563f'; return; }
         rp('#rp-msg').textContent = '✓ Staged (not posted). ' + (d.note || ''); rp('#rp-msg').style.color = 'var(--mut)';
diff --git a/public/panels/constant-contact.js b/public/panels/constant-contact.js
index e775821..8dc75ec 100644
--- a/public/panels/constant-contact.js
+++ b/public/panels/constant-contact.js
@@ -5,7 +5,7 @@ window.MCC_PANELS = window.MCC_PANELS || {};
 window.MCC_PANELS['constant-contact'] = {
   init(root) {
     const $ = s => root.querySelector(s);
-    const api = (p, opts) => fetch(`/api/constant-contact${p}`, opts).then(r => r.json());
+    const api = (p, opts) => fetch(`${location.origin}/api/constant-contact${p}`, Object.assign({ credentials: 'same-origin' }, opts)).then(r => r.json());
     const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
       ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
     const pct = n => `${(Number(n || 0) * 100).toFixed(1)}%`;
@@ -131,7 +131,7 @@ window.MCC_PANELS['constant-contact'] = {
       try {
         dry = await api('/schedule', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ draft_id: lastDraftId, html, confirm: true, dryRun: true }),
+          body: JSON.stringify({ draft_id: lastDraftId, html, subject: field('subject'), from: field('from'), confirm: true, dryRun: true }),
         });
       } catch (e) { setMsg(`Error: ${e.message}`, true); return; }
 
@@ -153,7 +153,7 @@ window.MCC_PANELS['constant-contact'] = {
       try {
         const r = await api('/schedule', {
           method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ draft_id: lastDraftId, scheduled_time: when || undefined, html, confirm: true, dryRun: false }),
+          body: JSON.stringify({ draft_id: lastDraftId, scheduled_time: when || undefined, html, subject: field('subject'), from: field('from'), confirm: true, dryRun: false }),
         });
         markMock(r.mock); banner();
         showCompliance(r.compliance);

← da76726 MCC officer-review fixes: fail-closed auth (remove hardcoded  ·  back to Marketing Command Center  ·  MCC polish: publish-time caveats for half-wired channels (Ti 535807b →