diff --git a/runtime/core.mjs b/runtime/core.mjs index 34d4633..4f437e1 100644 --- a/runtime/core.mjs +++ b/runtime/core.mjs @@ -6,7 +6,7 @@ // Todo lo que hace queda en actions_log. "Lo no auditado no existió." import pg from 'pg' -import { loadIdentityBlock } from './outbox.mjs' +import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs' import { chat } from './llm.mjs' const { diff --git a/runtime/daemon.mjs b/runtime/daemon.mjs index d697346..4c889d8 100644 --- a/runtime/daemon.mjs +++ b/runtime/daemon.mjs @@ -13,7 +13,7 @@ import { SimplePool } from 'nostr-tools/pool' import { nip04, nip44 } from 'nostr-tools' import pg from 'pg' import { writeFileSync, unlinkSync, readdirSync } from 'node:fs' -import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs' +import { publishMode, gateOutbound, ensureOutboxTable } from '/srv/cayce-core/outbox.mjs' const PORT = process.env.PORT || 3011 const NSEC_HEX = process.env.NSEC_HEX @@ -424,9 +424,17 @@ const server = http.createServer(async (req, res) => { tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]), content: '', }, sk) - await pool.publish(RELAYS, ev) - console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`) - return json(200, { id: ev.id, follows: pubkeys.length }) + // fix fuga (2026-11-01): el kind 3 publicaba directo sin pasar por el + // gate — fue la VIOLACION kind 3 del 2026-11-01T00:00:04Z vista por [silence] + const g = await gateOutbound(poolPg, { + channel: 'nostr', type: 'contact_list', content: '', meta: { kind: 3, follows: pubkeys.length }, + publishFn: async () => { + await pool.publish(RELAYS, ev) + console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`) + return { id: ev.id } + }, + }) + return json(200, { id: ev.id, follows: pubkeys.length, guarded: true, ...g }) } catch (err) { return json(500, { error: String(err).slice(0, 200) }) } }) return diff --git a/runtime/deploy.sh b/runtime/deploy.sh index 85f4c96..8ee5875 100755 --- a/runtime/deploy.sh +++ b/runtime/deploy.sh @@ -9,14 +9,12 @@ install -m 644 "$SRC/outbox.mjs" /srv/cayce-core/outbox.mjs install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs -install -m 644 "$SRC/outbox.mjs" /srv/cayce-nostr/outbox.mjs +# ronda-7: outbox UNICO en /srv/cayce-core, import absolutos install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs # opcionales (llegan con el PR de verificación/10050) install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs 2>/dev/null || true install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs 2>/dev/null || true -install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-social/outbox.mjs install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs -install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-mail/outbox.mjs install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs # config de contención (idempotente): draft por defecto + ruta de identidad diff --git a/runtime/editorials.mjs b/runtime/editorials.mjs index 79379aa..84064f9 100644 --- a/runtime/editorials.mjs +++ b/runtime/editorials.mjs @@ -6,7 +6,7 @@ import pg from 'pg' import { readFileSync } from 'node:fs' -import { loadIdentityBlock } from './outbox.mjs' +import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs' import { SimplePool } from 'nostr-tools/pool' import { chat } from './llm.mjs' diff --git a/runtime/guard-blocklist.txt b/runtime/guard-blocklist.txt new file mode 100644 index 0000000..f370039 --- /dev/null +++ b/runtime/guard-blocklist.txt @@ -0,0 +1,23 @@ +# publish-guard: marcas bloqueadas (hardware wallets / cold storage) — directiva 2026-11-01 +# una marca por linea, matching por palabra completa, case-insensitive. Ampliable. +ledger +ledger nano +trezor +coldcard +coinkite +bitbox +bitbox02 +keystone wallet +blockstream jade +passport wallet +foundation passport +safepal +ngrave +seedsigner +cypherock +tangem +onekey +d'cent +ellipal +opendime +seedplate diff --git a/runtime/guard.mjs b/runtime/guard.mjs new file mode 100644 index 0000000..af85593 --- /dev/null +++ b/runtime/guard.mjs @@ -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 } +} diff --git a/runtime/guard.test.mjs b/runtime/guard.test.mjs new file mode 100644 index 0000000..b72b240 --- /dev/null +++ b/runtime/guard.test.mjs @@ -0,0 +1,98 @@ +// guard.test.mjs — tests del publish-guard (directiva 2026-11-01) +import test from 'node:test' +import assert from 'node:assert' +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +const require_ = createRequire(import.meta.url) +const pg = require_('/srv/cayce-core/node_modules/pg') +const env = Object.fromEntries(readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])) +const pool = new pg.Pool({ connectionString: env.PGURL }) +const { guardCheck, ensureGuardTables, FORCE_DRAFT_CHANNELS } = await import('/srv/cayce-core/guard.mjs') +await ensureGuardTables(pool) +const clean = async () => { + await pool.query("DELETE FROM outbound_drafts WHERE meta->>'test' = '1'") + await pool.query("DELETE FROM guard_rejections WHERE content LIKE '%__TEST__%'") +} +test.after(() => pool.end()) + +test('R1 marca de hardware wallet bloqueada', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' }) + assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons)) +}) +test('R2a enlace de referido', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' }) + assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons)) +}) +test('R2b hashtag', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' }) + assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons)) +}) +test('R2c emoji', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' }) + assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons)) +}) +test('R2d signo de exclamacion', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') }) + assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons)) +}) +test('R3 hecho sin fuente y fecha', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' }) + assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons)) +}) +test('R3 pasa con fuente y fecha', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) +test('R4 casi-duplicado 30 dias', async () => { + await clean() + await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')", + ['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })]) + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market priced the halving with astonishing precision and nobody noticed anything at all today __TEST__' }) + assert.ok(!v.ok && v.reasons.some(r => r.startsWith('casi-duplicado')), JSON.stringify(v.reasons)) + await clean() +}) +test('R5 tope diario por canal', async () => { + await clean() + for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })]) + const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' }) + assert.ok(!v.ok && v.reasons.some(r => r.startsWith('tope-diario:nostr')), JSON.stringify(v.reasons)) + await clean() +}) +test('R6 canales siempre en borrador', async () => { + assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal']) +}) +test('metadata (kind 3) exenta de reglas de texto', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'contact_list', content: '' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) + +test('R7 fuente mayor de 72h en canal de noticias', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' }) + assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons)) +}) +test('R7 pasa con fuente fresca', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) +test('R7 no aplica a bitcoinhotels', async () => { + const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) +test('R8 cierre aforismo corto sin dato', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' }) + assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons)) +}) +test('R8 pasa si el cierre trae dato', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) + +test('R8 regression: cola (source: ...) no cuenta como cierre', async () => { + const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 and Tron carried 242 million for the group. The blocklist missed Tron and 242 million dollars noticed. (source: https://www.theblock.co/x, Nov 1 2026) __TEST__' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) + +test('R8 refinado: cierre con superlativo/record aporta dato', async () => { + const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' }) + assert.ok(v.ok, JSON.stringify(v.reasons)) +}) diff --git a/runtime/initiative.mjs b/runtime/initiative.mjs index b8b09a2..380d6be 100644 --- a/runtime/initiative.mjs +++ b/runtime/initiative.mjs @@ -180,13 +180,23 @@ async function runCycle() { === DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) === 1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2. 2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador. -3. La contención está activa: publish_note crea BORRADORES, no publica. Sigue contando como acción productiva. +3. Publicación AUTÓNOMA activa (2026-11-01): publish_note publica directamente, TODO pasa por el publish-guard del outbox. Si el guard rechaza, registra el motivo y sigue. Kill switch inmediato: /srv/cayce-core/PUBLISH_OFF. ` : '' +// directiva del supervisor (2026-11-01): cold storage sin marcas. Reversible: +// CAYCE_DIRECTIVE_COLDSTORAGE=0 en el .env. +const DIRECTIVE_CS = process.env.CAYCE_DIRECTIVE_COLDSTORAGE === '0' ? '' : ` + +=== DIRECTIVA DEL SUPERVISOR (2026-11-01 — vigente hasta nuevo aviso) === +4. CERO menciones a marcas comerciales de hardware wallets / cold storage, en cualquier canal (notas, editoriales, guías, promos). +5. Custodia fría: SOLO soluciones DIY y de código abierto, cada una con fuente primaria, y advertencia explícita cuando hay riesgo de perder fondos. +6. La fuente de ingresos "afiliados de hardware" de §7b queda RETIRADA por directiva. El MANDATE no se toca. +` const messages = [ { role: 'user', content: `${MANDATE} ${DIRECTIVE} +${DIRECTIVE_CS} === TU ESTADO ACTUAL === ${JSON.stringify(state, null, 2)} diff --git a/runtime/mail.mjs b/runtime/mail.mjs index 3e8e925..c714916 100644 --- a/runtime/mail.mjs +++ b/runtime/mail.mjs @@ -6,7 +6,7 @@ import { ImapFlow } from 'imapflow' import nodemailer from 'nodemailer' import { readFileSync, writeFileSync, existsSync } from 'node:fs' import pg from 'pg' -import { gateOutbound } from './outbox.mjs' +import { gateOutbound } from '/srv/cayce-core/outbox.mjs' const HOME = '/home/ubuntu/cayce-mail' const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])) diff --git a/runtime/outbox.mjs b/runtime/outbox.mjs index 4ca6b00..6b976e8 100644 --- a/runtime/outbox.mjs +++ b/runtime/outbox.mjs @@ -11,7 +11,8 @@ // // Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live). -import { readFileSync } from 'node:fs' +import { readFileSync, existsSync } from 'node:fs' +import { guardCheck, logRejection, recordPublish, ensureGuardTables, FORCE_DRAFT_CHANNELS } from './guard.mjs' import { createHash } from 'node:crypto' import { createRequire } from 'node:module' @@ -20,6 +21,8 @@ const require_ = createRequire(import.meta.url) const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md' export function publishMode() { + // kill switch instantáneo (2026-11-01): touch /srv/cayce-core/PUBLISH_OFF + if (existsSync('/srv/cayce-core/PUBLISH_OFF')) return 'draft' return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase() } @@ -93,15 +96,34 @@ export async function stageDraft(poolPg, { channel, type, content, original_even } // El único punto por el que debe pasar cualquier salida a terceros. -// publishFn SOLO se invoca en modo live. +// publish-guard (2026-11-01): TODA salida pasa por guardCheck() ANTES de +// publicarse o guardarse. Rechazos → tabla guard_rejections (revisable). +// mail/proposal/dm SIEMPRE borrador (política del propietario). export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) { + const p = await poolFor(poolPg) + await ensureGuardTables(p) + if (FORCE_DRAFT_CHANNELS.includes(channel)) { + try { + return await stageDraft(p, { channel, type, content, original_event, meta }) + } catch (e) { + console.error(`[outbox] FATAL staging canal-forzado (${String(e).slice(0, 160)}) — descartado`) + return { staged: false, error: 'staging failed; discarded (fail-closed)' } + } + } + const v = await guardCheck(p, { channel, type, content }) + if (!v.ok) { + await logRejection(p, { channel, type, content, reasons: v.reasons }) + return { staged: false, rejected: true, reasons: v.reasons } + } if (publishMode() === 'live') { - return { ...(await publishFn()), staged: false } + const r = await publishFn() + // watcher: el id del evento publicado queda registrado como aprobacion + await recordPublish(p, { channel, type, content, meta: { ...meta, event_id: r && r.id ? r.id : undefined } }) + return { ...r, staged: false, guarded: true } } try { - return await stageDraft(poolPg, { channel, type, content, original_event, meta }) + return await stageDraft(p, { channel, type, content, original_event, meta }) } catch (e) { - // fail-closed: sin borrador guardado, no se publica console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`) return { staged: false, error: 'staging failed; discarded (fail-closed)' } } diff --git a/runtime/promo.mjs b/runtime/promo.mjs index 397070f..3412b84 100644 --- a/runtime/promo.mjs +++ b/runtime/promo.mjs @@ -4,7 +4,7 @@ // Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly. // Ledger en DB (posts_log) para medir y optimizar. import { execFileSync } from 'node:child_process' -import { gateOutbound } from './outbox.mjs' +import { gateOutbound } from '/srv/cayce-core/outbox.mjs' import { readFileSync, writeFileSync, existsSync } from 'node:fs' const HOME = '/home/ubuntu/cayce-social' diff --git a/runtime/relay-watcher.mjs b/runtime/relay-watcher.mjs new file mode 100644 index 0000000..09a0d4d --- /dev/null +++ b/runtime/relay-watcher.mjs @@ -0,0 +1,60 @@ +// relay-watcher — cualquier evento de Cayce en relays sin aprobacion registrada +// => kill switch + DM al supervisor + fila de auditoria. Cada 5 min (timer). +import { SimplePool } from 'nostr-tools' +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { createRequire } from 'node:module' +const req = createRequire(import.meta.url) +const pg = req('/srv/cayce-core/node_modules/pg') +const env = Object.fromEntries(readFileSync('/srv/cayce-core/.env','utf8').split('\n').filter(l=>l.includes('=')).map(l=>[l.slice(0,l.indexOf('=')),l.slice(l.indexOf('=')+1)])) +const pool = new pg.Pool({ connectionString: env.PGURL }) +const PK = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855' +const RELAYS = ['wss://relay.damus.io','wss://nos.lol','wss://relay.primal.net','wss://relay.snort.social','wss://nostr-pub.wellorder.net'] +const CURSOR = '/srv/cayce-core/.watcher-cursor' +const KILL = '/srv/cayce-core/PUBLISH_OFF' +const now = Math.floor(Date.now()/1000) +const cursor = existsSync(CURSOR) ? Number(readFileSync(CURSOR,'utf8').trim()) : now - 3600 +const since = Math.max(cursor - 60, now - 3600) +const p = new SimplePool({ eoseSubTimeout: 12000 }) +const evs = await Promise.race([ + p.querySync(RELAYS, { authors: [PK], since }, 15000).catch(() => []), + new Promise(r => setTimeout(() => r([]), 20000)), +]) +p.close(RELAYS) +const uniq = new Map(); for (const e of evs) uniq.set(e.id, e) +let violations = [] +for (const e of uniq.values()) { + let ok = false + if ([0, 3, 10050].includes(e.kind)) { + const { rows } = await pool.query( + "SELECT 1 FROM outbound_drafts WHERE channel='nostr' AND type IN ('profile','contact_list','inbox_relays') AND ts BETWEEN to_timestamp($1) - interval '15 minutes' AND to_timestamp($1) + interval '15 minutes' LIMIT 1", + [e.created_at]) + ok = rows.length > 0 + } else if (e.kind === 5) { + const { rows } = await pool.query("SELECT 1 FROM outbound_drafts WHERE type='deletion' AND meta->>'kind5_id' = $1 LIMIT 1", [e.id]) + ok = rows.length > 0 + } else { + const { rows } = await pool.query( + "SELECT 1 FROM outbound_drafts WHERE meta->>'event_id' = $1 OR (channel='nostr' AND content = left($2, 7900) AND ts BETWEEN to_timestamp($3) - interval '25 minutes' AND to_timestamp($3) + interval '25 minutes') LIMIT 1", + [e.id, String(e.content||''), e.created_at]) + ok = rows.length > 0 + } + if (!ok) violations.push(e) +} +let maxTs = since +for (const e of uniq.values()) maxTs = Math.max(maxTs, e.created_at) +writeFileSync(CURSOR, String(maxTs)) +if (violations.length) { + writeFileSync(KILL, 'watcher ' + new Date().toISOString() + ' — ' + violations.map(v => v.id).join(',')) + for (const v of violations) { + await pool.query("INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ('nostr','watcher_violation',$1,$2)", + ['evento sin aprobacion: kind ' + v.kind + ' id ' + v.id, 'KILL SWITCH ACTIVADO. ' + String(v.content||'').slice(0, 300)]) + } + const SUP = (readFileSync('/srv/cayce-nostr/.env','utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim() + const msg = 'WATCHER: ' + violations.length + ' evento(s) SIN aprobacion del guard. Kill switch ACTIVADO (PUBLISH_OFF). ' + violations.map(v => v.id.slice(0,16) + ' kind' + v.kind).join(' · ') + try { await fetch('http://127.0.0.1:3011/dm', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ to_hex: SUP, content: msg }) }) } catch {} + console.log('[watcher] VIOLACION:', msg) +} else { + console.log('[watcher] ok:', uniq.size, 'eventos revisados, 0 sin aprobacion') +} +await pool.end() +process.exit(0) diff --git a/runtime/verify-identity.mjs b/runtime/verify-identity.mjs index 5546e83..b423448 100644 --- a/runtime/verify-identity.mjs +++ b/runtime/verify-identity.mjs @@ -1,4 +1,4 @@ -import { loadIdentityBlock, identityFingerprint } from './outbox.mjs' +import { loadIdentityBlock, identityFingerprint } from '/srv/cayce-core/outbox.mjs' import { readFileSync } from 'node:fs' const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')