// guard.mjs — PUBLISH-GUARD v1 (directiva del propietario 2026-11-01) // Un solo punto de paso: lo llama gateOutbound() en cayce-core y cayce-social, // y el gate de publicación de hardcap. Rechaza, registra el motivo, nunca publica. import { readFileSync } from 'node:fs' const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt' export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays', 'deletion'] // no caps, no formato texto const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 } function caps() { try { return { ...DEFAULT_CAPS, ...JSON.parse(process.env.GUARD_CAPS_JSON || '{}') } } catch { return DEFAULT_CAPS } } function blocklist() { try { return readFileSync(BLOCKLIST_PATH, 'utf8').split('\n') .map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.toLowerCase()) } catch { return [] } } const URL_RE = /https?:\/\/[^\s)>"]+/gi const stripUrls = s => String(s).replace(URL_RE, ' ') // ── reglas ─────────────────────────────────────────────────────────────────── // politica ronda-7 punto 4.6: R1 aplica SOLO a la voz propia de Cayce. // Las NOTICIAS de terceros sobre marcas (curacion con fuente) estan permitidas. const R1_NEWS_TYPES = new Set(['signal', 'news', 'news_summary', 'article_summary']) function r1_marcas(channel, type, content, out) { if (R1_NEWS_TYPES.has(type)) return const c = stripUrls(content).toLowerCase() for (const b of blocklist()) { const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i') if (re.test(c)) { out.push(`marca-bloqueada:${b}`); return } } } function r2_formato(content, out) { const c = stripUrls(content) if (/[?&](aff|ref|refid|refer)=[a-z0-9_-]+|\/ref\/|\/r\/[a-z0-9_-]{2,}|affiliate|referral|amzn\.to|[?&]tag=/i.test(String(content))) out.push('enlace-afiliado-o-referido') if (/(^|\s)#[a-z0-9_]+/i.test(c)) out.push('hashtag') if (/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}]/u.test(c)) out.push('emoji') if (/!/.test(c)) out.push('signo-de-exclamacion') } const DATE_RE = /(\b(19|20)\d{2}[-/.](0?[1-9]|1[0-2])[-/.](0?[1-9]|[12]\d|3[01])\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+(19|20)\d{2}\b|\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(19|20)\d{2}\b|\b(19|20)\d{2}\b)/i const FACT_RE = /(\d+(\.\d+)?\s*(%|usdt|usd|btc|sats?|vb|million|billion|bill|mill)|\b(19|20)\d{2}\b|\b(first|largest|biggest|most|record|all-time|only|highest|lowest|never|first ever)\b)/i function r3_hechos(content, out) { const s = String(content) const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0 if (FACT_RE.test(s) && (!hasUrl || !DATE_RE.test(s))) out.push('afirmacion-factica-sin-fuente-y-fecha') } function r4_duplicado() { /* se resuelve con pool en guardCheck (async) */ } function shingles(s) { const w = stripUrls(s).toLowerCase().replace(/[^a-z0-9áéíóúñ\s]/g, ' ').split(/\s+/).filter(x => x.length > 2) const sh = new Set() for (let i = 0; i + 2 < w.length; i++) sh.add(w.slice(i, i + 3).join(' ')) if (!sh.size && w.length) sh.add(w.join(' ')) return sh } function jaccard(a, b) { let i = 0; for (const x of a) if (b.has(x)) i++; return i / (a.size + b.size - i || 1) } const DUP_THRESHOLD = 0.55 const NEWS_CHANNELS = new Set(['nostr', 'hardcap']) const FRESH_WINDOW_MS = 72 * 3600e3 function parseDates(s) { const ds = [] const iso = /\b(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\b/g for (const m of s.matchAll(iso)) ds.push(new Date(+m[1], +m[2] - 1, +m[3])) const mon = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(20\d{2})\b/gi const MO = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 } for (const m of s.matchAll(mon)) ds.push(new Date(+m[3], MO[m[1].slice(0, 3).toLowerCase()], +m[2])) return ds } function r7_frescura(channel, content, out) { if (!NEWS_CHANNELS.has(channel)) return const s = String(content) const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0 if (!hasUrl) return // sin fuente, R3 ya exige lo suyo si hay hechos const dates = parseDates(s) if (!dates.length) return // sin fecha: R3 se encarga const newest = new Date(Math.max(...dates.map(d => d.getTime()))) if (Date.now() - newest.getTime() > FRESH_WINDOW_MS) out.push(`fuente-mayor-72h (${newest.toISOString().slice(0, 10)})`) } function r8_cierre(content, out) { const s = String(content).trim() if (!s) return const sentences = s.split(/(?<=[.?])\s+/).filter(Boolean) let last = sentences[sentences.length - 1] || '' // la atribución de fuente final no cuenta como "cierre" last = last.replace(/(source|fuente)\s*:.*/i, '').replace(/https?:\/\/[^\s)>"]+/g, '').trim().replace(/[.?\s]+$/, '') if (!/[a-z0-9]/i.test(last)) last = (sentences[sentences.length - 2] || '').replace(/(source|fuente)\s*:.*/i, '').trim() if (!last) return const words = last.split(/\s+/).filter(Boolean) if (!words.length) return // aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6 const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || /\b(first|largest|biggest|most|record|all-time|highest|lowest)\b/i.test(last) if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato') } async function r4_casiDuplicado(pool, content, out) { if (!pool) return const since = new Date(Date.now() - 30 * 86400e3) const cand = [] try { const a = await pool.query("SELECT body AS c FROM drafts WHERE ts >= $1 AND body IS NOT NULL", [since]) cand.push(...a.rows.map(r => r.c)) const b = await pool.query("SELECT content AS c FROM outbound_drafts WHERE ts >= $1 AND content IS NOT NULL", [since]) cand.push(...b.rows.map(r => r.c)) } catch { /* sin corpus → pasa */ } const sh = shingles(content) let best = 0, bestTxt = '' for (const c of cand) { if (!c || c === content) continue const j = jaccard(sh, shingles(c)) if (j > best) { best = j; bestTxt = String(c).slice(0, 60) } } if (best >= DUP_THRESHOLD) out.push(`casi-duplicado (jaccard=${best.toFixed(2)} vs "${bestTxt}…")`) } async function r5_caps(pool, channel, type, out) { if (!pool) return if (METADATA_TYPES.includes(type)) return let ch = channel if (/facebook/i.test(type)) ch = 'facebook' else if (/instagram/i.test(type)) ch = 'instagram' const cap = caps()[ch] if (!cap) return try { const r = await pool.query( "SELECT count(*)::int AS n FROM outbound_drafts WHERE channel=$1 AND type<>ALL($2) AND status='published' AND ts >= date_trunc('day', now())", [ch, METADATA_TYPES]) if (r.rows[0].n >= cap) out.push(`tope-diario:${ch}(${r.rows[0].n}/${cap})`) } catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ } } // R9 (ronda-14 → ronda-16): la pieza debe compartir TERMINOS ESPECIFICOS de su // fuente: nombres propios, cifras y palabras clave del titular (slug del URL y, // si se pasa, sourceTitle). El vocabulario generico ("wallet", "chain") no // verifica nada: un texto de BTCPay con fuente de una planta de gas en // Wisconsin paso el guard el 2026-11-02 porque el cuerpo tenia palabras de // dominio genericas. Ahora el cuerpo debe contener al menos un termino // distintivo DE ESA FUENTE; si no, se rechaza. const R9_STOP = new Set(['https', 'http', 'www', 'html', 'php', 'asp', 'com', 'net', 'org', 'co', 'io', 'gov', 'edu', 'news', 'article', 'articles', 'story', 'stories', 'post', 'posts', 'blog', 'index', 'page', 'pages', 'url', 'link', 'video', 'watch', 'pdf', 'summary', 'summaries', 'en', 'es', 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'what', 'when', 'where', 'which', 'while', 'would', 'could', 'should', 'about', 'after', 'before', 'over', 'under', 'between', 'through', 'during', 'their', 'there', 'these', 'those', 'other', 'more', 'most', 'than', 'then', 'they', 'them', 'were', 'have', 'having', 'been', 'being', 'into', 'onto', 'upon', 'says', 'said', 'amid', 'after', 'because', 'still', 'just', 'also', 'first', 'last']) function r9_sourceTerms(url, title) { const terms = new Set() // palabras clave del slug del URL (derivadas del titular): se descartan // dominio, stopwords y ids opacos (letras+digitos, largos: no son titulares) const slugWords = [] for (const part of String(url || '').split(/[/?#]/).slice(3)) { for (const w of part.split(/[-_.+]+/)) { const t = w.toLowerCase() if (!t || t.length < 5 || R9_STOP.has(t) || /^(19|20)\d{2}$/.test(t)) continue if (/\d/.test(t) && /[a-z]/.test(t) && t.length >= 10) continue // id opaco slugWords.push(t) } } // cifras distintivas (>=3 digitos): del titular o del slug ya filtrado — // nunca de dentro de un id opaco (sus digitos no son del titular) for (const m of `${title || ''} ${slugWords.join(' ')}`.matchAll(/\d[\d.,]*/g)) { const raw = m[0].replace(/[.,]/g, '') if (raw.length >= 3) terms.add(raw) } for (const t of slugWords) terms.add(t) // nombres propios y palabras del titular (si el ciclo lo pasa como sourceTitle) for (const w of String(title || '').split(/[^A-Za-z0-9]+/)) { if (!w) continue const t = w.toLowerCase() if (/^(19|20)\d{2}$/.test(t)) continue if ((/^[A-Z]/.test(w) && w.length >= 3 || t.length >= 5) && !R9_STOP.has(t)) terms.add(t) } return terms } function r9_atribucion(full, reasons, sourceTitle = null) { const idx = full.indexOf('\n\n\u2014 fuente: ') if (idx === -1) return const body = full.slice(0, idx).toLowerCase() const fuente = full.slice(idx) const words = body.split(/[^a-z0-9]+/).filter(w => w.length > 4) if (words.length < 5) return const url = (fuente.match(/https?:\/\/[^\s·]+/) || [''])[0] const terms = r9_sourceTerms(url, sourceTitle) if (!terms.size) return // fuente sin terminos distinguibles (shortener/id): R9 no puede verificar const bodyTokens = new Set(body.split(/[^a-z0-9]+/).filter(Boolean)) if (![...terms].some(t => bodyTokens.has(t))) { reasons.push('R9-atribucion-no-verificable') } } // ── API ────────────────────────────────────────────────────────────────────── export function ensureGuardTables(pool) { return pool.query(` CREATE TABLE IF NOT EXISTS guard_rejections ( id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), channel text NOT NULL, type text NOT NULL, reasons text NOT NULL, content text NOT NULL )`) } export async function guardCheck(pool, { channel, type, content, sourceTitle = null }) { const reasons = [] const full = String(content ?? '') // ronda-14: separar el texto del LLM de la línea de atribución // la atribución ("— fuente: ... · ...") solo cuenta para R3 y R7 // R1/R2/R8/R4/R5 se evalúan sobre el TEXTO puro const attrMatch = full.match(/\n\n\u2014 fuente: .+$/) const attribution = attrMatch ? attrMatch[0] : '' const body = attrMatch ? full.slice(0, attrMatch.index) : full r1_marcas(channel, type, body, reasons) if (!METADATA_TYPES.includes(type)) { r2_formato(body, reasons) r3_hechos(full, reasons) r7_frescura(channel, full, reasons) r8_cierre(body, reasons) r9_atribucion(full, reasons, sourceTitle) await r4_casiDuplicado(pool, body, reasons) await r5_caps(pool, channel, type, reasons) } return { ok: reasons.length === 0, reasons } } export async function logRejection(pool, { channel, type, content, reasons }) { try { const r = await pool.query( 'INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ($1,$2,$3,$4) RETURNING id', [channel, type, reasons.join('; '), String(content).slice(0, 5000)]) console.log(`[guard] RECHAZADO (${channel}/${type}): ${reasons.join('; ')}`) return r.rows[0].id } catch (e) { console.error('[guard] logRejection:', String(e).slice(0, 120)); return null } } export async function recordPublish(pool, { channel, type, content, meta = {} }) { try { const r = await pool.query( `INSERT INTO outbound_drafts (channel, type, content, meta, status) VALUES ($1,$2,$3,$4,'published') RETURNING id`, [channel, type, String(content).slice(0, 8000), JSON.stringify(meta)]) return r.rows[0].id } catch (e) { console.error('[guard] recordPublish:', String(e).slice(0, 120)); return null } }