runtime: outbox UNICO (imports absolutos) + relay-watcher con kill switch + guard R1-R8
ci / lint-test-leaks (pull_request) Failing after 3s
ci / lint-test-leaks (pull_request) Failing after 3s
- outbox.mjs solo en /srv/cayce-core; daemon/social/mail/ops lo importan por ruta absoluta. deploy.sh ya no instala copias. - relay-watcher.mjs: cada 5 min cruza eventos de la pubkey en 5 relays con outbound_drafts; sin aprobacion => PUBLISH_OFF + DM + audit. Probado: detecto 2 kind0 bypass reales (post-hoc registrados). - guard R1-R8 (18 tests) + blocklist + check-single-outbox al check diario. - stale duplicados archivados en forensics/scripts-archive-20261102 (nostr-daemon.mjs, dm-debug, publish-10050, dm-test2, find-followers).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// 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'] // 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 ───────────────────────────────────────────────────────────────────
|
||||
function r1_marcas(content, out) {
|
||||
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(' ')) || FACT_RE.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) */ }
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
const reasons = []
|
||||
const body = String(content ?? '')
|
||||
r1_marcas(body, reasons)
|
||||
if (!METADATA_TYPES.includes(type)) {
|
||||
r2_formato(body, reasons)
|
||||
r3_hechos(body, reasons)
|
||||
r7_frescura(channel, body, reasons)
|
||||
r8_cierre(body, reasons)
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user