← back to Compliance Agent
src/server.js
242 lines
// compliance-agent — internal "do not contact" suppression gate + audit log
// http://127.0.0.1:9853
//
// Every email/SMS/call MUST hit /api/check then /api/log. George's /api/send
// is wrapped to call this gate before delivering. Future SMS/call agents do the same.
//
// Federal note: there is NO public Do Not Email list. The federal Do Not Call
// Registry exists but requires a paid FTC SAN to access; this service starts
// as an internal-only suppression gate. When phone/SMS automation ships, plug
// a paid lookup (Twilio Lookup, RealPhoneValidation, or FTC SAN) into
// checkPhoneAgainstDNC() — currently returns null (= "no external check ran").
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');
const { Pool } = require('pg');
const PORT = parseInt(process.env.PORT || '9853', 10);
const ADMIN_USER = process.env.COMPLIANCE_ADMIN_USER || 'admin';
// No hardcoded fallback: if COMPLIANCE_ADMIN_PASS is unset, use a random
// per-boot secret so admin login is disabled-by-default rather than shipping a
// known password. Set COMPLIANCE_ADMIN_PASS in the gitignored .env to enable.
const ADMIN_PASS = process.env.COMPLIANCE_ADMIN_PASS || crypto.randomBytes(24).toString('hex');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://stevestudio2@/compliance?host=/tmp',
});
const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json({ limit: '5mb' }));
// ---- normalization ----
function normalizePhone(raw) {
if (!raw) return '';
const digits = String(raw).replace(/\D/g, '');
if (digits.length === 10) return '+1' + digits;
if (digits.length === 11 && digits.startsWith('1')) return '+' + digits;
if (digits.startsWith('00')) return '+' + digits.slice(2);
if (digits.length >= 8) return '+' + digits;
return digits;
}
function normalizeEmail(raw) {
if (!raw) return '';
return String(raw).trim().toLowerCase();
}
function bodyHash(body) {
if (!body) return null;
return crypto.createHash('sha256').update(String(body)).digest('hex').slice(0, 16);
}
// ---- auth (admin endpoints) ----
function requireAdmin(req, res, next) {
const auth = req.headers.authorization || '';
if (!auth.startsWith('Basic ')) return res.status(401).set('WWW-Authenticate', 'Basic').json({ error: 'auth required' });
const [user, pass] = Buffer.from(auth.slice(6), 'base64').toString().split(':');
if (user !== ADMIN_USER || pass !== ADMIN_PASS) return res.status(403).json({ error: 'forbidden' });
next();
}
// ---- federal DNC stub ----
// Returns: { source, onDNC, error } or null when no external lookup configured.
async function checkPhoneAgainstDNC(/* e164 */) {
// Plug in Twilio Lookup / RealPhoneValidation / FTC SAN here when ready.
return null;
}
// ---- routes ----
app.get('/health', (req, res) => res.json({ status: 'ok', agent: 'compliance', port: PORT, uptime: process.uptime() }));
// Check phone against suppression list (and external DNC when configured)
app.post('/api/check/phone', async (req, res) => {
try {
const { phone, channel = 'sms' } = req.body || {};
if (!phone) return res.status(400).json({ error: 'phone required' });
const e164 = normalizePhone(phone);
const r = await pool.query(
`SELECT e164, source, reason, added_at, channel FROM suppression_phone WHERE e164 = $1`,
[e164]
);
const onSuppression = r.rowCount > 0;
const dnc = await checkPhoneAgainstDNC(e164);
const blockedByExternal = dnc && dnc.onDNC === true;
res.json({
e164, channel,
onSuppression,
onExternalDNC: dnc ? dnc.onDNC : null,
externalSource: dnc ? dnc.source : null,
allowed: !onSuppression && !blockedByExternal,
record: onSuppression ? r.rows[0] : null,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/check/email', async (req, res) => {
try {
const { email } = req.body || {};
if (!email) return res.status(400).json({ error: 'email required' });
const norm = normalizeEmail(email);
const r = await pool.query(
`SELECT email, source, reason, added_at FROM suppression_email WHERE email = $1`,
[norm]
);
const onSuppression = r.rowCount > 0;
res.json({
email: norm,
onSuppression,
allowed: !onSuppression,
record: onSuppression ? r.rows[0] : null,
// CAN-SPAM: there's no federal email DNC, so allowed === !onSuppression
note: onSuppression ? 'on internal suppression list' : 'not suppressed (no federal email DNC exists)',
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Combined log — record EVERY outbound contact attempt, allowed or blocked
app.post('/api/log/contact', async (req, res) => {
try {
const { channel, recipient, sender_agent, subject_or_summary, body, allowed, block_reason, on_suppression, dnc_check_source } = req.body || {};
if (!channel || !recipient || typeof allowed !== 'boolean') {
return res.status(400).json({ error: 'channel, recipient, allowed required' });
}
const norm = channel === 'email' ? normalizeEmail(recipient) : normalizePhone(recipient);
const r = await pool.query(
`INSERT INTO contact_log (channel, recipient, recipient_normalized, sender_agent, subject_or_summary, body_hash, allowed, block_reason, on_suppression, dnc_check_source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id, ts`,
[channel, recipient, norm, sender_agent || null, (subject_or_summary || '').slice(0, 500), bodyHash(body), allowed, block_reason || null, !!on_suppression, dnc_check_source || null]
);
res.json({ id: r.rows[0].id, ts: r.rows[0].ts });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// One-shot "guard" endpoint: combines check + log in a single call
// Senders should prefer this. Returns allowed=true/false; if false, do NOT send.
app.post('/api/guard', async (req, res) => {
try {
const { channel, recipient, sender_agent, subject_or_summary, body } = req.body || {};
if (!channel || !recipient) return res.status(400).json({ error: 'channel + recipient required' });
let onSuppression = false, blockedByExternal = false, externalSource = null;
if (channel === 'email') {
const norm = normalizeEmail(recipient);
const r = await pool.query(`SELECT 1 FROM suppression_email WHERE email = $1`, [norm]);
onSuppression = r.rowCount > 0;
} else if (channel === 'sms' || channel === 'phone') {
const e164 = normalizePhone(recipient);
const r = await pool.query(`SELECT 1 FROM suppression_phone WHERE e164 = $1 AND (channel = 'all' OR channel = $2)`, [e164, channel]);
onSuppression = r.rowCount > 0;
const dnc = await checkPhoneAgainstDNC(e164);
blockedByExternal = dnc && dnc.onDNC === true;
externalSource = dnc ? dnc.source : null;
} else {
return res.status(400).json({ error: 'channel must be email|sms|phone' });
}
const allowed = !onSuppression && !blockedByExternal;
const reason = !allowed ? (onSuppression ? 'internal-suppression' : 'external-dnc') : null;
const norm = channel === 'email' ? normalizeEmail(recipient) : normalizePhone(recipient);
const log = await pool.query(
`INSERT INTO contact_log (channel, recipient, recipient_normalized, sender_agent, subject_or_summary, body_hash, allowed, block_reason, on_suppression, dnc_check_source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
[channel, recipient, norm, sender_agent || null, (subject_or_summary || '').slice(0, 500), bodyHash(body), allowed, reason, onSuppression, externalSource]
);
res.json({ allowed, blockReason: reason, logId: log.rows[0].id, onSuppression, externalSource });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Add to suppression
app.post('/api/suppress/phone', requireAdmin, async (req, res) => {
try {
const { phone, source, reason, channel = 'all' } = req.body || {};
if (!phone || !source) return res.status(400).json({ error: 'phone + source required' });
const e164 = normalizePhone(phone);
await pool.query(
`INSERT INTO suppression_phone (e164, source, reason, channel) VALUES ($1,$2,$3,$4)
ON CONFLICT (e164) DO UPDATE SET source = EXCLUDED.source, reason = EXCLUDED.reason, channel = EXCLUDED.channel`,
[e164, source, reason || null, channel]
);
res.json({ added: e164 });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/suppress/email', requireAdmin, async (req, res) => {
try {
const { email, source, reason } = req.body || {};
if (!email || !source) return res.status(400).json({ error: 'email + source required' });
const norm = normalizeEmail(email);
await pool.query(
`INSERT INTO suppression_email (email, source, reason) VALUES ($1,$2,$3)
ON CONFLICT (email) DO UPDATE SET source = EXCLUDED.source, reason = EXCLUDED.reason`,
[norm, source, reason || null]
);
res.json({ added: norm });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/suppress/phone/:e164', requireAdmin, async (req, res) => {
await pool.query(`DELETE FROM suppression_phone WHERE e164 = $1`, [req.params.e164]);
res.json({ removed: req.params.e164 });
});
app.delete('/api/suppress/email/:email', requireAdmin, async (req, res) => {
await pool.query(`DELETE FROM suppression_email WHERE email = $1`, [normalizeEmail(req.params.email)]);
res.json({ removed: req.params.email });
});
// Audit
app.get('/api/audit/recent', requireAdmin, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
const r = await pool.query(`SELECT * FROM contact_log ORDER BY ts DESC LIMIT $1`, [limit]);
res.json({ count: r.rowCount, items: r.rows });
});
app.get('/api/audit/stats', requireAdmin, async (req, res) => {
const r = await pool.query(`
SELECT
COUNT(*)::int AS total,
SUM(CASE WHEN allowed THEN 1 ELSE 0 END)::int AS allowed_count,
SUM(CASE WHEN NOT allowed THEN 1 ELSE 0 END)::int AS blocked_count,
SUM(CASE WHEN channel='email' THEN 1 ELSE 0 END)::int AS emails,
SUM(CASE WHEN channel='sms' THEN 1 ELSE 0 END)::int AS sms,
SUM(CASE WHEN channel='phone' THEN 1 ELSE 0 END)::int AS phone,
MAX(ts) AS last_ts
FROM contact_log
`);
const s = await pool.query(`
SELECT
(SELECT COUNT(*)::int FROM suppression_phone) AS suppression_phone,
(SELECT COUNT(*)::int FROM suppression_email) AS suppression_email
`);
res.json({ ...r.rows[0], suppression: s.rows[0] });
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`compliance-agent listening on http://127.0.0.1:${PORT}`);
});