← back to NationalPaperHangers
lib/compliance.js
177 lines
// Communications-compliance gate. Fail-closed pre-flight + per-recipient
// scrub utilities. Every outbound send script MUST call assertSendCompliance()
// before its loop and isSuppressed()/recordAudit() per recipient.
//
// Standing rules enforced (from Steve's comms-compliance agent + MEMORY.md):
// - CAN-SPAM §7(a)(5): physical postal address must appear in every commercial email body.
// - CAN-SPAM functional opt-out: unsubscribe link must be present + the suppression scrub must run.
// - CA §17529.5: subject + From accurate, no deceptive headers.
// - CCPA: opt-outs and deletes must be honored (suppression list).
// - Steve's rule: per-message audit to PG (`comms_send_audit`); fail-closed if list missing or stale.
const crypto = require('crypto');
const db = require('./db');
const IS_PROD = process.env.NODE_ENV === 'production';
class ComplianceError extends Error {
constructor(code, msg) { super(msg); this.code = code; }
}
function hasMailingAddress() {
const a = (process.env.MAILING_ADDRESS || '').trim();
if (!a) return false;
if (/TBD|placeholder|TODO|FIXME|localhost/i.test(a)) return false;
// Crude shape check: at least 3 comma-separated parts (street, city, state-zip).
return a.split(',').filter(Boolean).length >= 3;
}
function publicUrlOk() {
const u = (process.env.PUBLIC_URL || '').trim();
if (!u) return false;
if (u.includes('localhost') || u.includes('127.0.0.1')) return false;
if (IS_PROD && !u.startsWith('https://')) return false;
return true;
}
async function tablesExist() {
const r = await db.query(
`SELECT
to_regclass('public.comms_suppression') IS NOT NULL AS sup,
to_regclass('public.comms_send_audit') IS NOT NULL AS aud,
to_regclass('public.unsubscribe_tokens') IS NOT NULL AS tok`
);
const row = r.rows[0] || {};
return !!(row.sup && row.aud && row.tok);
}
// Pre-flight gate. Throws ComplianceError if any prerequisite is missing.
// Call BEFORE entering any send loop. Per-recipient scrub still required after.
async function assertSendCompliance({ campaign }) {
if (!campaign) throw new ComplianceError('no_campaign', 'campaign label is required');
if (!hasMailingAddress()) {
throw new ComplianceError(
'no_mailing_address',
'MAILING_ADDRESS env var is missing or placeholder. CAN-SPAM §7(a)(5) requires a real, deliverable street address in every outbound footer. Set it in .env (format: "Studio Name, 123 Real Street, City, ST 12345"), then retry.'
);
}
if (!publicUrlOk()) {
throw new ComplianceError(
'public_url_invalid',
'PUBLIC_URL must be a real https URL in production (or at minimum non-localhost in dev) so unsubscribe + claim links resolve. Currently: ' + (process.env.PUBLIC_URL || '<unset>')
);
}
if (IS_PROD && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'dev-secret-change-me')) {
throw new ComplianceError('no_session_secret', 'SESSION_SECRET must be set in production');
}
if (!process.env.UNSUBSCRIBE_SIGNING_SECRET && !process.env.SESSION_SECRET) {
throw new ComplianceError('no_unsub_key', 'UNSUBSCRIBE_SIGNING_SECRET (or SESSION_SECRET fallback) must be set');
}
const ok = await tablesExist();
if (!ok) {
throw new ComplianceError(
'no_compliance_tables',
'comms_suppression / comms_send_audit / unsubscribe_tokens tables are missing in `national_paper_hangers`. Apply db/migrations/004_comms_suppression.sql before sending.'
);
}
}
// Per-recipient scrub. Returns true if the address is suppressed.
async function isSuppressed({ channel, identifier }) {
if (!channel || !identifier) return true; // fail-closed on bad input
const id = String(identifier).trim().toLowerCase();
const r = await db.query(
`SELECT 1 FROM comms_suppression WHERE channel=$1 AND identifier=$2 LIMIT 1`,
[channel, id]
);
return r.rowCount > 0;
}
async function addSuppression({ channel, identifier, reason, source, installerId, notes }) {
await db.query(
`INSERT INTO comms_suppression (channel, identifier, reason, source, installer_id, notes)
VALUES ($1, lower($2), $3, $4, $5, $6)
ON CONFLICT (channel, identifier) DO NOTHING`,
[channel, identifier, reason, source || null, installerId || null, notes || null]
);
}
async function recordAudit({ channel, campaign, recipient, installerId, decision, reason, subject, messageId, payload }) {
await db.query(
`INSERT INTO comms_send_audit
(channel, campaign, recipient, installer_id, decision, reason, subject, message_id, payload)
VALUES ($1,$2,lower($3),$4,$5,$6,$7,$8,$9)`,
[channel, campaign, recipient, installerId || null, decision, reason || null, subject || null, messageId || null, payload ? JSON.stringify(payload) : null]
);
}
// Unsubscribe-token helpers. The token is opaque; the row carries the channel,
// identifier, and campaign so the /unsubscribe handler can act without trust
// in URL params.
function unsubKey() {
return process.env.UNSUBSCRIBE_SIGNING_SECRET || process.env.SESSION_SECRET || 'dev-only-not-for-prod';
}
async function mintUnsubscribeToken({ channel, identifier, campaign, installerId }) {
const raw = `${channel}:${String(identifier).toLowerCase()}:${campaign}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
const token = crypto.createHmac('sha256', unsubKey()).update(raw).digest('base64url').slice(0, 32);
await db.query(
`INSERT INTO unsubscribe_tokens (token, channel, identifier, campaign, installer_id)
VALUES ($1,$2,lower($3),$4,$5) ON CONFLICT (token) DO NOTHING`,
[token, channel, identifier, campaign || null, installerId || null]
);
return token;
}
async function consumeUnsubscribeToken(token) {
if (!token || typeof token !== 'string' || token.length < 16) return null;
const r = await db.query(
`UPDATE unsubscribe_tokens
SET used_at = COALESCE(used_at, now())
WHERE token = $1
RETURNING channel, identifier, campaign, installer_id, used_at`,
[token]
);
return r.rows[0] || null;
}
function complianceFooter({ campaign, unsubscribeUrl }) {
// Plain HTML footer to be appended to every commercial email body.
// Address comes from MAILING_ADDRESS at runtime — assertSendCompliance() has
// already verified it's set when this runs.
const addr = (process.env.MAILING_ADDRESS || '').trim();
return `<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:11px;color:#888;line-height:1.5">
This email was sent by National Paper Hangers, ${escapeHtml(addr)}.
${unsubscribeUrl ? `<br><a href="${unsubscribeUrl}" style="color:#888">Unsubscribe</a> from ${escapeHtml(campaign || 'these emails')}.` : ''}
</p>`;
}
function listUnsubscribeHeader(unsubscribeUrl) {
// RFC 2369 + RFC 8058 headers — Gmail / Outlook surface a 1-click button when present.
if (!unsubscribeUrl) return null;
return {
'List-Unsubscribe': `<${unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
};
}
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
module.exports = {
ComplianceError,
assertSendCompliance,
isSuppressed,
addSuppression,
recordAudit,
mintUnsubscribeToken,
consumeUnsubscribeToken,
complianceFooter,
listUnsubscribeHeader,
hasMailingAddress,
publicUrlOk
};