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).
131 lines
5.4 KiB
JavaScript
131 lines
5.4 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, 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(`<!-- 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 = 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)' }
|
|
}
|
|
}
|