Files
cayce-ops/runtime/outbox.mjs
cayce b81f7f3625
ci / lint-test-leaks (pull_request) Canceled after 0s
containment: every third-party send becomes a draft (outbox), identity file versioned
Problem: Cayce publishes straight to relays with no approval gate —
cayce-initiative publish_note, core.mjs digest/opinion/reflection/events,
mention auto-replies, social promo (Facebook/Instagram via sfeed), and
mail — while the operator believed approvals existed (they never did;
ADR-0005 Telegram queue was never implemented).

Mechanism (outbox.mjs, deployed to all runtimes):
- CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound
  artifact into outbound_drafts (entity DB) with identity_path +
  identity_hash (sha256 of prompts/cayce-identity.md) before anything
  else happens. Fail-closed: if staging fails, the event is discarded,
  never published.
- CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with
  one line per service .env).
- Chokepoint: the nostr daemon /publish endpoint gates ALL callers
  (initiative publish_note, core.mjs ×4, banner kind 0); the daemon own
  mention-reply path gates too, storing original_event for context;
  startup profile publish gates as well. DM-to-supervisor (/dm) is
  internal and stays live.

Identity (point 8b):
- prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted
  VERBATIM (verified byte-identical by runtime test) with block markers.
- core.mjs / editorials.mjs now load their voice blocks from this file
  (loadIdentityBlock); identity hash recorded on every draft.
- runtime/ holds the versioned copies of the previously-unversioned
  runtime scripts + deploy.sh installer.

No timers enabled or disabled. No production data modified.
2026-10-31 16:44:57 +00:00

106 lines
4.1 KiB
JavaScript

// 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'
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(`<!-- cayce-identity:block=${name} -->\\n([\\s\\S]*?)\\n<!-- /cayce-identity:block=${name} -->`)
)
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 = (await import('pg')).default
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)' }
}
}