// 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 } from 'node:fs' 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() { 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. // publishFn SOLO se invoca en modo live. export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) { if (publishMode() === 'live') { return { ...(await publishFn()), staged: false } } try { return await stageDraft(poolPg, { 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)' } } }