// outbox.mjs — contención de salida a terceros (prioridad Cayce, 2026-10-31) // // CAYCE_PUBLISH_MODE=draft (default) → NADA sale a terceros: todo artefacto // (notas nostr, respuestas a menciones, posts a redes sociales, mail) se // guarda como BORRADOR en outbound_drafts con el hash del archivo de // identidad vigente. El DM al supervisor NO pasa por aquí (es interno). // CAYCE_PUBLISH_MODE=live → comportamiento original, sin cambios. // // Fail-closed: en modo draft, si el guardado del borrador falla, NO se // publica. Nunca. El error queda en el journal. // // Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live). 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' 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() } let identityCache = null export function identityFingerprint() { if (identityCache) return identityCache try { const raw = readFileSync(IDENTITY_PATH) identityCache = { path: IDENTITY_PATH, hash: createHash('sha256').update(raw).digest('hex'), } } catch (e) { identityCache = { path: IDENTITY_PATH, hash: null, error: String(e).slice(0, 120) } } return identityCache } export function loadIdentityBlock(name) { const raw = readFileSync(IDENTITY_PATH, 'utf8') const m = raw.match( new RegExp(`\\n([\\s\\S]*?)\\n`) ) if (!m) throw new Error(`identity block not found: ${name}`) return m[1] } export function ensureOutboxTable(poolPg) { return poolPg.query(` CREATE TABLE IF NOT EXISTS outbound_drafts ( id bigserial PRIMARY KEY, ts timestamptz NOT NULL DEFAULT now(), channel text NOT NULL, type text NOT NULL, content text NOT NULL, original_event jsonb, identity_path text, identity_hash text, status text NOT NULL DEFAULT 'pending', meta jsonb )`).catch(e => console.error('[outbox] ensure table:', String(e).slice(0, 120))) } // pool auto-suficiente: si el host no pasa uno, se conecta a la BD de la // entidad (PGURL de /srv/cayce-core/.env) para que todos los borradores // vivan juntos en outbound_drafts let ownPool = null async function poolFor(poolPg) { if (poolPg) return poolPg if (!ownPool) { 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)]) ) ownPool = new pg.Pool({ connectionString: env.PGURL }) } return ownPool } export async function stageDraft(poolPg, { channel, type, content, original_event = null, meta = {} }) { poolPg = await poolFor(poolPg) const id = identityFingerprint() const r = await poolPg.query( `INSERT INTO outbound_drafts (channel, type, content, original_event, identity_path, identity_hash, meta) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, [channel, type, String(content), original_event ? JSON.stringify(original_event) : null, id.path, id.hash, JSON.stringify(meta)] ) console.log(`[outbox] STAGED draft #${r.rows[0].id} (${channel}/${type}) — NO publicado (mode=${publishMode()})`) return { staged: true, draft_id: r.rows[0].id, mode: publishMode() } } // El único punto por el que debe pasar cualquier salida a terceros. // 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') { 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(p, { channel, type, content, original_event, meta }) } catch (e) { console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`) return { staged: false, error: 'staging failed; discarded (fail-closed)' } } }