// 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)