← back to Marketing Command Center
server.js
407 lines
#!/usr/bin/env node
// Marketing Command Center — modular dashboard shell.
// Mounts each module from modules/registry.js under /api/<id>, serves its panel
// from public/panels/<id>.{html,js}, and renders a nav shell. Basic-auth gated.
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Load .env file into process.env (zero-dep; file is the source of truth so pm2
// daemon-env drift can't override it). Existing process.env wins only if a key
// isn't in the file.
(function loadEnv() {
try {
for (const line of fs.readFileSync(path.join(__dirname, '.env'), 'utf8').split('\n')) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
} catch { /* no .env — use process.env / defaults */ }
})();
const PORT = process.env.PORT || 9661;
const app = express();
// 28mb so the Asset Library can accept base64-encoded image uploads (the assets
// module also mounts its own 28mb parser on /upload as a belt-and-suspenders).
app.use(express.json({ limit: '28mb' }));
// ── Basic auth (matches DW fleet pattern; cred overridable via env) ──────────
const USER = process.env.MCC_USER || 'admin';
// No fallback: fail closed if MCC_PASS is unset so a misconfigured box denies
// all access rather than shipping a known default. (A hardcoded default was
// previously here AND in the committed .env.example — treat that value as burned
// and rotate it; this box must set MCC_PASS in its own .env.)
const PASS = process.env.MCC_PASS || '';
if (!PASS) console.warn('[mcc] MCC_PASS is unset — all requests will be denied until it is configured.');
// Auth accepts the standard Basic header OR an `mcc` cookie we set on first
// success. The cookie is what makes XHR/fetch work after the page is opened
// with embedded URL credentials (http://user:pass@host) — Chrome won't re-attach
// those creds to subresource/fetch requests, so without the cookie every
// /api/* fetch 401s and the shell hangs forever on "Loading…".
app.use((req, res, next) => {
const hdr = req.headers.authorization || '';
let b64 = hdr.startsWith('Basic ') ? hdr.slice(6) : '';
if (!b64) {
const m = (req.headers.cookie || '').match(/(?:^|;\s*)mcc=([^;]+)/);
if (m) b64 = decodeURIComponent(m[1]);
}
const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
if (PASS && u === USER && p === PASS) {
// HttpOnly so XSS can't read the credential-equivalent cookie; Secure only
// when the request arrived over HTTPS so local plain-http :9661 still works.
const secure = (req.headers['x-forwarded-proto'] === 'https' || req.secure) ? ' Secure;' : '';
res.setHeader('Set-Cookie', `mcc=${encodeURIComponent(b64)}; Path=/; Max-Age=604800; HttpOnly;${secure} SameSite=Lax`);
return next();
}
res.set('WWW-Authenticate', 'Basic realm="Marketing Command Center"').status(401).end('Auth required');
});
// ── Mount modules ────────────────────────────────────────────────────────────
const registry = require('./modules/registry');
const panels = [];
for (const id of registry) {
try {
const mod = require(`./modules/${id}`);
const router = express.Router();
if (typeof mod.mount === 'function') mod.mount(router);
app.use(`/api/${mod.id || id}`, router);
panels.push({ id: mod.id || id, title: mod.title || id, icon: mod.icon || '▪' });
console.log(`[mcc] mounted module: ${mod.id || id}`);
} catch (e) {
// A module that isn't built yet shouldn't crash the whole center.
console.warn(`[mcc] module '${id}' not mounted: ${e.message}`);
panels.push({ id, title: id, icon: '▪', pending: true });
}
}
// ── Shell APIs + static ──────────────────────────────────────────────────────
app.get('/api/panels', (_req, res) => res.json({ panels }));
app.get('/api/health', (_req, res) => res.json({ ok: true, panels: panels.length }));
// ── Per-contact notes for the Clients & Prospects panel ──────────────────────
// Server-side store so notes follow the user across devices (the panel's
// localStorage is just an instant cache). Not a module — a notes STORE has no
// nav panel of its own, so it mounts here instead of via the registry.
// File: data/clients-notes.json → { "<groupId>": { "<recId>": { text, updated } } }
const NOTES_FILE = path.join(__dirname, 'data', 'clients-notes.json');
const readNotes = () => { try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')); } catch { return {}; } };
const writeNotes = (obj) => { const tmp = NOTES_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(obj)); fs.renameSync(tmp, NOTES_FILE); };
app.get('/api/clients-notes', (req, res) => {
const group = String(req.query.group || '');
if (!group) return res.status(400).json({ error: 'group required' });
const g = readNotes()[group] || {};
const notes = {};
for (const id of Object.keys(g)) notes[id] = (g[id] && g[id].text) || '';
res.json({ group, notes });
});
app.post('/api/clients-notes', (req, res) => {
const { group, id } = req.body || {};
let { text } = req.body || {};
if (!group || !id || typeof group !== 'string' || typeof id !== 'string') return res.status(400).json({ error: 'group and id required' });
if (group.length > 400 || id.length > 400) return res.status(400).json({ error: 'key too long' });
text = typeof text === 'string' ? text.slice(0, 8000) : '';
const all = readNotes();
const g = all[group] || (all[group] = {});
if (text.trim()) g[id] = { text, updated: Date.now() };
else delete g[id];
if (!Object.keys(g).length) delete all[group];
try { writeNotes(all); } catch { return res.status(500).json({ error: 'write failed' }); }
res.json({ ok: true });
});
// ── Canonical DW-owned Instagram accounts (the 35) ───────────────────────────
// One source of truth every social/account panel reads so all owned accounts show
// up everywhere — not just the ones that happen to have posts/data on a surface.
// Not a module (no nav panel of its own), so it mounts here like clients-notes.
// File: data/dw-ig-accounts.json — regenerated by scripts/build-dw-accounts.js.
const DW_ACCOUNTS_FILE = path.join(__dirname, 'data', 'dw-ig-accounts.json');
app.get('/api/dw-accounts', (_req, res) => {
try {
const d = JSON.parse(fs.readFileSync(DW_ACCOUNTS_FILE, 'utf8'));
res.json({ ok: true, count: (d.accounts || []).length, generated_at: d.generated_at || null, accounts: d.accounts || [] });
} catch (e) {
res.status(500).json({ ok: false, error: 'dw-accounts unavailable', accounts: [] });
}
});
// ── Vendor-amplify draft store (Feature B: "Stage drafts") ───────────────────
// A DEDICATED drafts file for the #vendors panel's per-account amplify captions.
// HARD RAIL: this writes ONLY to its own data/vendor-amplify-drafts.json — never
// to the live-authoritative engine-queue.json / channels-outbox.json / meta-pages.json
// (a stale write to those once wiped a live Meta token). Staging here does NOT
// publish anything — actually posting to the selected IG accounts is a gated
// send-to-list action owned by Steve. This endpoint is a review/hand-off staging
// area, nothing more. Not a nav module, so it mounts here like clients-notes.
// Shape: append-only array of
// { id, staged_at, vendor, postImage, permalink, targetAccounts:[handle],
// perAccountCaptions:{ handle: text } }
const AMP_DRAFTS_FILE = path.join(__dirname, 'data', 'vendor-amplify-drafts.json');
const readAmpDrafts = () => { try { const d = JSON.parse(fs.readFileSync(AMP_DRAFTS_FILE, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; } };
const writeAmpDrafts = (arr) => {
fs.mkdirSync(path.dirname(AMP_DRAFTS_FILE), { recursive: true });
const tmp = AMP_DRAFTS_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
fs.renameSync(tmp, AMP_DRAFTS_FILE); // atomic swap — no partial/corrupt reads
};
app.get('/api/vendor-amplify-drafts', (_req, res) => {
res.json({ ok: true, drafts: readAmpDrafts().slice().reverse() });
});
app.post('/api/vendor-amplify-drafts', (req, res) => {
// ── server-side schema validation (codex trap: never trust the client shape) ─
const b = req.body || {};
const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
const postImage = typeof b.postImage === 'string' ? b.postImage.slice(0, 2000) : '';
const permalink = typeof b.permalink === 'string' ? b.permalink.slice(0, 2000) : '';
const targetsRaw = Array.isArray(b.targetAccounts) ? b.targetAccounts : [];
const capsRaw = (b.perAccountCaptions && typeof b.perAccountCaptions === 'object' && !Array.isArray(b.perAccountCaptions)) ? b.perAccountCaptions : {};
// Normalize handles (strip @, cap length, cap count) and keep only captions for
// targeted handles. Reject an empty/oversized submission outright.
const targetAccounts = [...new Set(targetsRaw
.filter(h => typeof h === 'string')
.map(h => h.replace(/^@/, '').trim().slice(0, 80))
.filter(Boolean))].slice(0, 60);
if (!targetAccounts.length) return res.status(400).json({ ok: false, error: 'no target accounts' });
const perAccountCaptions = {};
for (const h of targetAccounts) {
const t = capsRaw[h];
perAccountCaptions[h] = (typeof t === 'string' ? t : '').slice(0, 4000);
}
const draft = {
id: 'vad_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
staged_at: new Date().toISOString(),
vendor, postImage, permalink, targetAccounts, perAccountCaptions,
};
try {
const all = readAmpDrafts();
all.push(draft);
writeAmpDrafts(all.slice(-2000)); // bound the file
} catch (e) {
return res.status(500).json({ ok: false, error: 'write failed' });
}
res.json({ ok: true, id: draft.id, staged: targetAccounts.length });
});
// ── "Make it ours" creative image system (TK-10842) ──────────────────────────
// Transforms a vendor's pattern image into a GENUINELY DW-ORIGINAL branded asset
// DW can post as its own creative, so amplifying a vendor post never reposts the
// vendor's raw copyrighted photo. Two treatments:
// Treatment 1 "Branded card" ($0 local) — the client composites the pattern onto
// a DW-branded canvas (wordmark + hairline frame + availability strip) via the
// browser Canvas API and POSTs the finished PNG here. Transformative brand
// creative, no external API. (sharp/canvas aren't installed in this app, so the
// composite is done client-side per the task's pure-canvas fallback.)
// Treatment 2 "Room setting" (small $) — server calls the EXISTING DW
// room-setting-generator pipeline (:8106) to produce an ORIGINAL DW room mockup.
//
// HARD COMPLIANCE GATES (baked in, never skipped):
// (1) SETTLEMENT — every asset (AI or composite) runs through the settlement
// post-gen-vision gate (lib/settlement-gate.js, canonical Gemini-vision port)
// BEFORE it is saved/shown as final. BLOCK/NEEDS_REVIEW => not staged as final;
// the reason is surfaced.
// (2) NEVER-FRONT-FACING — restricted (never-front-facing) brands get NO creative
// controls in the panel; this endpoint also refuses a restricted vendor name.
// (3) NOTHING AUTO-POSTS — assets save to public/amp-assets/ (gitignored +
// rsync-excluded so a deploy never clobbers remote-generated assets). Attaching
// to a staged draft REPLACES the raw vendor photo (draft.dwAsset). No IG/Meta
// publish call anywhere; engine-queue/channels-outbox/meta-pages untouched.
// (4) SHOW COST — Treatment 1 = "$0 local"; Treatment 2 = its real per-image rate;
// paid renders logged via the cost-tracker skill.
const { settlementGateBuffer } = require('./lib/settlement-gate');
const { generateRoom, roomAppUp, ROOM_RENDER_COST } = require('./lib/room-render');
// Same never-front-facing guard the panel uses (kept in sync — extend as policy
// dictates). A restricted vendor must produce NO creative asset here.
const isRestrictedVendor = (name) => /\bschumacher\b/i.test(String(name || ''));
// Saved-asset registry (side-file, gitignored + rsync-excluded) — lets the panel
// list/attach assets and survives a re-render. NOT the live channels/engine store.
const AMP_ASSETS_DIR = path.join(__dirname, 'public', 'amp-assets');
const AMP_ASSETS_INDEX = path.join(__dirname, 'data', 'vendor-amplify-assets.json');
const readAmpAssets = () => { try { const d = JSON.parse(fs.readFileSync(AMP_ASSETS_INDEX, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; } };
const writeAmpAssets = (arr) => {
fs.mkdirSync(path.dirname(AMP_ASSETS_INDEX), { recursive: true });
const tmp = AMP_ASSETS_INDEX + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
fs.renameSync(tmp, AMP_ASSETS_INDEX); // atomic swap
};
// Persist a produced asset buffer to public/amp-assets/ + register it. Atomic write.
const saveAmpAsset = (buf, ext, meta) => {
fs.mkdirSync(AMP_ASSETS_DIR, { recursive: true });
const id = 'amp_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const fname = `${id}.${ext}`;
const tmp = path.join(AMP_ASSETS_DIR, fname + '.tmp');
fs.writeFileSync(tmp, buf);
fs.renameSync(tmp, path.join(AMP_ASSETS_DIR, fname));
const rec = Object.assign({ id, file: fname, url: '/amp-assets/' + fname, created_at: new Date().toISOString(), bytes: buf.length }, meta || {});
const all = readAmpAssets(); all.push(rec); writeAmpAssets(all.slice(-2000));
return rec;
};
// Best-effort cost log to the cost-tracker skill (never blocks the response).
const logCost = (units, note) => {
try {
const { spawn } = require('child_process');
const script = path.join(os.homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js');
if (!fs.existsSync(script)) return;
spawn('node', [script, '--api', 'replicate_sdxl', '--units', units, '--app', 'marketing-command-center', '--note', note || 'vendor-amplify make-it-ours'], { detached: true, stdio: 'ignore' }).unref();
} catch { /* cost log is best-effort */ }
};
// Decode a data: URL or bare base64 into { buffer, mime, ext }. Rejects non-images.
const decodeImage = (dataUrl) => {
const s = String(dataUrl || '');
const m = s.match(/^data:(image\/(png|jpeg|jpg|webp));base64,(.+)$/);
const b64 = m ? m[3] : s.replace(/^data:[^,]*,/, '');
const mime = m ? m[1] : 'image/png';
const ext = /jpe?g/.test(mime) ? 'jpg' : (/webp/.test(mime) ? 'webp' : 'png');
const buffer = Buffer.from(b64, 'base64');
return { buffer, mime, ext };
};
// Attach a saved DW asset to an existing staged draft, REPLACING the raw vendor
// photo as the image-to-post (draft.dwAsset). Returns {ok, matched}. Never posts.
const attachAssetToDraft = (draftId, asset) => {
if (!draftId) return { ok: false, matched: false };
const all = readAmpDrafts();
let matched = false;
for (const d of all) {
if (d && d.id === draftId) {
d.dwAsset = { url: asset.url, id: asset.id, treatment: asset.treatment || null, settlement: asset.settlement || null, attached_at: new Date().toISOString() };
d.originalPostImage = d.originalPostImage || d.postImage || '';
d.postImage = asset.url; // the DW-original asset is now the image-to-post
matched = true;
break;
}
}
if (matched) { try { writeAmpDrafts(all); } catch { return { ok: false, matched: true }; } }
return { ok: matched, matched };
};
// Treatment 1 — save a client-composited branded card. body:
// { vendor, treatment:'branded-card', image:<dataURL png>, variant:'1080'|'1350',
// attachToDraftId?:string }
app.post('/api/vendor-amplify-assets', async (req, res) => {
const b = req.body || {};
const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
if (isRestrictedVendor(vendor)) return res.status(403).json({ ok: false, error: 'restricted brand — no creative allowed' });
const treatment = (typeof b.treatment === 'string' && b.treatment) ? b.treatment.slice(0, 40) : 'branded-card';
let img;
try { img = decodeImage(b.image); } catch { return res.status(400).json({ ok: false, error: 'bad image' }); }
if (!img.buffer || !img.buffer.length) return res.status(400).json({ ok: false, error: 'empty image' });
if (img.buffer.length > 12 * 1024 * 1024) return res.status(413).json({ ok: false, error: 'image too large' });
// (1) SETTLEMENT gate — even the plain composite is vision-checked per the task.
let gate;
try { gate = await settlementGateBuffer(img.buffer, img.mime, vendor + ' branded card'); }
catch (e) { gate = { verdict: 'NEEDS_REVIEW', reason: 'gate error ' + String(e && e.message || e).slice(0, 100), cost: 0 }; }
if (gate.verdict === 'BLOCK') {
return res.json({ ok: false, blocked: true, verdict: gate.verdict, reason: gate.reason, cost: gate.cost, costLabel: '$0 (local)' });
}
// Save regardless of OK vs NEEDS_REVIEW (NEEDS_REVIEW is a human-eyes flag, not a
// hard block); the record carries the verdict so the UI + any downstream step can
// gate the actual (Steve-owned) posting decision on it.
const asset = saveAmpAsset(img.buffer, img.ext, {
vendor, treatment, variant: (b.variant === '1350' ? '1080x1350' : '1080x1080'),
settlement: { verdict: gate.verdict, reason: gate.reason },
});
let attach = { ok: false, matched: false };
if (b.attachToDraftId) attach = attachAssetToDraft(String(b.attachToDraftId).slice(0, 80), Object.assign({ treatment }, asset));
res.json({
ok: true,
asset: { id: asset.id, url: asset.url, variant: asset.variant, bytes: asset.bytes },
verdict: gate.verdict, reason: gate.reason,
cost: 0, costLabel: '$0 (local)', // the composite itself is $0; the gate call is negligible
attached: attach.matched ? attach.ok : undefined,
});
});
// Treatment 2 — server-side DW-original room render via the existing pipeline. body:
// { vendor, patternBase64, roomType?, angle?, cameraDistance?, attachToDraftId? }
app.post('/api/vendor-amplify-room', async (req, res) => {
const b = req.body || {};
const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
if (isRestrictedVendor(vendor)) return res.status(403).json({ ok: false, error: 'restricted brand — no creative allowed' });
const patternBase64 = typeof b.patternBase64 === 'string' ? b.patternBase64 : '';
if (!patternBase64) return res.status(400).json({ ok: false, error: 'no pattern image' });
if (!(await roomAppUp())) {
return res.status(503).json({ ok: false, error: 'room renderer unavailable — REPLICATE_API_TOKEN not set/reachable', costLabel: `$0 (not billed)` });
}
// (2) generate — the room app bills ~$0.01–0.04 once success comes back.
const gen = await generateRoom(patternBase64, {
roomType: b.roomType, angle: b.angle, cameraDistance: b.cameraDistance,
patternWidth: b.patternWidth, patternHeight: b.patternHeight,
});
if (!gen.ok) return res.status(502).json({ ok: false, error: gen.error || 'render failed', cost: gen.cost || 0, costLabel: '$0 (not billed)' });
logCost('1:image', `room render SDXL (${b.roomType || 'living_room'}) for ${vendor}`);
const costLabel = `$${gen.cost.toFixed(2)} (Replicate SDXL room render)`;
// (1) SETTLEMENT gate on the AI-generated render — MANDATORY.
let gate;
try { gate = await settlementGateBuffer(gen.buffer, gen.mime, vendor + ' room setting'); }
catch (e) { gate = { verdict: 'NEEDS_REVIEW', reason: 'gate error ' + String(e && e.message || e).slice(0, 100), cost: 0 }; }
if (gate.verdict === 'BLOCK') {
// Do NOT save/show a blocked AI asset. Cost was still incurred by the render.
return res.json({ ok: false, blocked: true, verdict: gate.verdict, reason: gate.reason, cost: gen.cost, costLabel });
}
const asset = saveAmpAsset(gen.buffer, 'jpg', {
vendor, treatment: 'room-setting', variant: '1248x832',
roomType: b.roomType || 'living_room',
settlement: { verdict: gate.verdict, reason: gate.reason },
});
let attach = { ok: false, matched: false };
if (b.attachToDraftId) attach = attachAssetToDraft(String(b.attachToDraftId).slice(0, 80), Object.assign({ treatment: 'room-setting' }, asset));
res.json({
ok: true,
asset: { id: asset.id, url: asset.url, variant: asset.variant, bytes: asset.bytes, roomType: asset.roomType },
verdict: gate.verdict, reason: gate.reason,
cost: gen.cost, costLabel,
attached: attach.matched ? attach.ok : undefined,
});
});
// List saved amplify assets (newest first) — for the panel's creative review area.
app.get('/api/vendor-amplify-assets', (_req, res) => {
res.json({ ok: true, assets: readAmpAssets().slice().reverse() });
});
// Same-origin image relay so the client Canvas compositor (Treatment 1) can READ
// the vendor pattern pixels without CORS-tainting the canvas (IG/fbcdn images are
// cross-origin and would otherwise block toDataURL). Read-only, image-only,
// size-capped, allowlisted to the known IG/FB CDN + product-image hosts. Not a
// general proxy — refuses anything that isn't an image from an allowed host.
const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com|\.licdn\.com)$/i;
app.get('/api/img-proxy', async (req, res) => {
const raw = String(req.query.u || '');
let u;
try { u = new URL(raw); } catch { return res.status(400).end('bad url'); }
if (!/^https?:$/.test(u.protocol)) return res.status(400).end('bad protocol');
if (!IMG_PROXY_HOSTS.test(u.hostname)) return res.status(403).end('host not allowed');
try {
const c = new AbortController();
const t = setTimeout(() => c.abort(), 15000);
const r = await fetch(u.href, { signal: c.signal, redirect: 'follow' });
clearTimeout(t);
const ct = (r.headers.get('content-type') || '').split(';')[0];
if (!r.ok || !/^image\//.test(ct)) return res.status(502).end('not an image');
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length > 20 * 1024 * 1024) return res.status(413).end('too large');
res.set('Content-Type', ct);
res.set('Cache-Control', 'private, max-age=3600');
res.set('Access-Control-Allow-Origin', '*'); // same-origin fetch, but explicit for the <img crossorigin>
res.end(buf);
} catch (e) {
res.status(502).end('proxy error');
}
});
app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.listen(PORT, () => console.log(`[marketing-command-center] http://127.0.0.1:${PORT} · ${panels.length} panels`));