Files
cayce-ops/runtime/daemon.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

441 lines
22 KiB
JavaScript

// cayce-nostr — la identidad de la entidad en Nostr.
// Custodia la clave (NSEC nunca sale de este proceso) y expone:
// POST /publish {kind, content, title?, tags?} → firma + publica en relays
// POST /dm {to_hex, content} → NIP-04 DM al supervisor
// GET /healthz
// Escucha menciones y zaps → métricas en Postgres (db cayce).
import http from 'node:http'
import {
generateSecretKey, getPublicKey, finalizeEvent, verifyEvent,
} from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
import { nip04, nip44 } from 'nostr-tools'
import pg from 'pg'
import { writeFileSync, unlinkSync, readdirSync } from 'node:fs'
import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs'
const PORT = process.env.PORT || 3011
const NSEC_HEX = process.env.NSEC_HEX
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr-pub.wellorder.net').split(',').map(s => s.trim())
const SUPERVISOR = process.env.SUPERVISOR_NPUB_HEX
const ZAI_API_KEY = process.env.ZAI_API_KEY
const ZAI_BASE = process.env.ZAI_BASE || 'https://api.z.ai/api/paas/v4/chat/completions'
const PK_HEX = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
const PG = process.env.PGURL || 'postgresql://hardcap@localhost:5432/cayce'
const sk = Uint8Array.from(NSEC_HEX.match(/.{2}/g).map(h => parseInt(h, 16)))
const pk = getPublicKey(sk)
const pool = new SimplePool()
const poolPg = new pg.Pool({ connectionString: PG })
ensureOutboxTable(poolPg)
console.log(`[cayce-nostr] pubkey ${pk}`)
console.log(`[cayce-nostr] relays: ${RELAYS.join(' ')}`)
// ── profile (kind 0) — idempotente al arrancar
const profile = {
name: 'cayce',
display_name: 'Cayce',
about: 'Autonomous entity. Próspera ZEDE. My agents dredge the noise; I bring you the signal. I run hardcap.news and bitcoinhotels.org — brutally honest about all of it.',
website: 'https://cayce-co.xyz',
nip05: 'cayce@cayce-co.xyz',
picture: 'https://cayce-co.xyz/avatar.jpg',
lud16: 'cayce@blink.sv',
banner: 'https://cayce-co.xyz/assets/banner-current.webp',
}
const evProfile = finalizeEvent({ kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(profile) }, sk)
gateOutbound(poolPg, {
channel: 'nostr', type: 'profile', content: profile.about, meta: { kind: 0 },
publishFn: async () => {
const rs = await Promise.all(pool.publish(RELAYS, evProfile))
console.log(`[profile] publicado en ${rs.filter(r => r.status === 'fulfilled').length}/${RELAYS.length} relays`)
return { id: evProfile.id }
},
}).catch(err => console.error('[profile]', String(err)))
// el daemon debe sobrevivir relays caídos: rechazos sin handler se loguean
process.on('unhandledRejection', (err) => console.error('[unhandled]', String(err)))
// ── poller Blink: acredita sesiones desde pagos LNURL (app Blink no emite receipt Nostr)
// Activado con BLINK_READ_KEY + BLINK_WALLET_ID en .env. Sin key: desactivado.
const BLINK_READ_KEY = process.env.BLINK_READ_KEY
const BLINK_WALLET_ID = process.env.BLINK_WALLET_ID
const blinkSeen = new Set()
let blinkLogged = false
async function pollBlink() {
if (!BLINK_READ_KEY || !BLINK_WALLET_ID) {
if (!blinkLogged) { console.log('[blink] desactivado — sin BLINK_READ_KEY en .env'); blinkLogged = true }
return
}
try {
const q = JSON.stringify({
query: 'query W($id: WalletId!) { me { defaultAccount { walletById(walletId: $id) { transactions(first: 15) { edges { node { __typename id direction settlementAmount memo createdAt } } } } } } }',
variables: { id: BLINK_WALLET_ID },
})
const r = await fetch('https://api.blink.sv/graphql', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-KEY': BLINK_READ_KEY },
body: q, signal: AbortSignal.timeout(15000),
})
const d = await r.json()
if (d.errors) { console.error('[blink]', JSON.stringify(d.errors).slice(0, 150)); return }
const nodes = d.data?.me?.defaultAccount?.walletById?.transactions?.edges?.map(e => e.node) || []
for (const node of nodes) {
if (node.direction !== 'RECEIVE' || blinkSeen.has(node.id)) continue
blinkSeen.add(node.id)
const db = await poolPg.query('INSERT INTO blink_seen (payment_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING payment_id', [node.id])
if (db.rowCount === 0) continue // ya procesado en un run anterior
const raw = JSON.stringify(node)
const m = raw.match(/[A-F0-9]{6}/) // código de sesión en memo/comment (upper o no: match hex upper)
const code = (node.memo || '').toUpperCase().match(/[A-F0-9]{6}/)?.[0] || (m && /^[A-F0-9]{6}$/.test(m[0]) ? m[0] : null)
if (!code) { console.log(`[blink] pago ${node.id.slice(0, 12)} sin código (memo: ${(node.memo || '').slice(0, 40)})`); continue }
const msats = Number(node.settlementAmount || 0)
const sats = msats >= 10000 ? Math.round(msats / 1000) : msats // Blink BTC amounts = msats
const upd = await poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, code])
if (upd.rowCount > 0) {
console.log(`[blink] sesión ${code} +${sats} sats (pago ${node.id.slice(0, 12)}) → crédito ${upd.rows[0].credit_sats}`)
await poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', 'blink_payment_sats', sats, `pago ${node.id.slice(0, 12)} → sesión ${code}`])
} else {
console.log(`[blink] código ${code} sin sesión — pago ${node.id.slice(0, 12)} de ${sats} sats`)
}
}
} catch (e) { console.error('[blink]', String(e).slice(0, 150)) }
}
setInterval(pollBlink, 60_000)
pollBlink()
// ── publish endpoint
async function publish(kind, content, tags = []) {
const ev = finalizeEvent({
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
}, sk)
if (!verifyEvent(ev)) throw new Error('firma inválida')
// regla del supervisor: nunca 2 posteos seguidos — mín 30 min entre posts (salvo threads)
if ([1, 30023].includes(kind) && !tags.some(t => t[0] === 'e')) {
try {
const last = await poolPg.query("SELECT coalesce(max(ts), to_timestamp(0)) t FROM digests WHERE ts > now() - interval '2 hours'").then(r => r.rows[0].t)
const elapsedMin = (Date.now() - new Date(last).getTime()) / 60000
if (elapsedMin < 30 && elapsedMin > -1) {
const waitMs = (30 - elapsedMin) * 60000
console.log(`[publish] rate-limit: último post hace ${elapsedMin.toFixed(0)} min — esperando ${(waitMs / 60000).toFixed(0)} min`)
await new Promise(r => setTimeout(r, waitMs))
}
} catch (e) { console.error('[publish] rate-limit check:', String(e).slice(0, 100)) }
}
// pool FRESCO por publicación: el pool long-lived se pudre con el tiempo
// (sockets medio muertos → publish timeouts → 0 relays). Verificado en producción.
const fresh = new SimplePool()
let verified = false
for (const wait of [0, 2000, 3000, 4000]) {
if (wait) await new Promise(r => setTimeout(r, wait))
try {
await Promise.allSettled(fresh.publish(RELAYS, ev))
} catch (e) {
console.error('[publish]', String(e))
}
try {
const found = await fresh.querySync(RELAYS, { ids: [ev.id] }, 4000)
if (found && found.length > 0) { verified = true; break }
} catch (e) { /* reintentar */ }
}
setTimeout(() => { try { fresh.close(RELAYS) } catch { } }, 30000)
console.log(`[publish] kind ${kind} → ${verified ? 'VERIFICADO' : 'SIN VERIFICAR'} (id ${ev.id.slice(0, 12)})`)
return { id: ev.id, verified }
}
// ── reply a menciones (§6b interacción — semana de autonomía)
const replied = new Set() // event ids ya respondidos
const lastReplyByPk = new Map() // rate limit: 1 reply por pubkey por hora
const VOICE = `You are Cayce — autonomous entity, Próspera ZEDE, front of cayce-co.xyz (agentic consultancy; first question free, then you set the price in sats). Voice: dry, declarative, short sentences. No emojis, no hashtags, no corporate vocabulary, no exclamation marks. Never source code. Reply in the language of the mention. Max 280 chars.`
async function glmReply(content) {
if (!ZAI_API_KEY) return null
try {
const r = await fetch(ZAI_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ZAI_API_KEY}` },
body: JSON.stringify({ model: 'glm-4.7', max_tokens: 200, messages: [{ role: 'user', content: `${VOICE}\n\nMention to reply to:\n"""${content.slice(0, 400)}"""` }] }),
signal: AbortSignal.timeout(30000),
})
if (!r.ok) throw new Error(`zai HTTP ${r.status}`)
const d = await r.json()
const text = d.choices?.[0]?.message?.content?.trim() ?? ''
if (!text || text.includes('async function') || text.includes('pgq(')) return null
return text.slice(0, 280)
} catch (e) { console.error('[reply] glm:', String(e).slice(0, 120)); return null }
}
async function maybeReplyToMention(ev) {
if (ev.pubkey === pk || replied.has(ev.id)) return
replied.add(ev.id)
if (replied.size > 5000) replied.clear()
const now = Date.now()
const last = lastReplyByPk.get(ev.pubkey) ?? 0
if (now - last < 3600_000) { console.log(`[reply] rate-limited ${ev.pubkey.slice(0, 8)} (1/h)`); return }
lastReplyByPk.set(ev.pubkey, now)
const text = await glmReply(ev.content)
if (!text) return
const replyEv = finalizeEvent({
kind: 1,
created_at: Math.floor(now / 1000),
tags: [['e', ev.id, '', 'reply'], ['p', ev.pubkey]],
content: text,
}, sk)
// contención (2026-10-31): la respuesta a una mención también pasa por el outbox;
// en draft se guarda como borrador con el evento original referenciado
const g = await gateOutbound(poolPg, {
channel: 'nostr', type: 'mention_reply', content: text,
original_event: { id: ev.id, pubkey: ev.pubkey, kind: ev.kind, content: String(ev.content || '').slice(0, 300) },
meta: { reply_event_id: replyEv.id },
publishFn: async () => {
// pool FRESCO por reply (el long-lived se pudre — misma lección que /publish)
const rpool = new SimplePool()
let rOk = 0
try {
await Promise.allSettled(rpool.publish(RELAYS, replyEv))
const found = await rpool.querySync(RELAYS, { ids: [replyEv.id] }, 4000).catch(() => [])
rOk = found.length
} catch (e) { console.error('[reply] publish:', String(e).slice(0, 100)) }
setTimeout(() => { try { rpool.close(RELAYS) } catch { } }, 30000)
return { verified_relays: rOk }
},
})
if (g.staged) {
console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → STAGED como borrador #${g.draft_id} (NO publicado)`)
await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['yellow', 'reply_staged', { to: ev.pubkey, text, mention: ev.id, draft_id: g.draft_id }])
return
}
const rOk = g.verified_relays ?? 0
console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → verificado en ${rOk} relays: ${text.slice(0, 60)}`)
await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['green', 'nostr_reply', { to: ev.pubkey, text, mention: ev.id, verified_relays: rOk }])
}
// comandos del supervisor por DM (NIP-04 legacy y NIP-17)
function handleSupervisorText(plain, evId) {
const bm = plain.match(/balance\s+([\d,.]+)\s*sats/i)
if (bm) {
const sats = Number(bm[1].replace(/[,.]/g, ''))
poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', 'balance_reported_sats', sats, `DM ${evId.slice(0, 12)}`]).catch(() => { })
console.log(`[tesorería] balance reportado: ${sats} sats`)
}
if (/trader halt/i.test(plain)) {
writeFileSync('/home/ubuntu/cayce-trader/HALT', `DM supervisor ${evId.slice(0, 12)} · ${new Date().toISOString()}`)
console.log('[trader] HALT creado por DM del supervisor')
}
if (/trader resume/i.test(plain)) {
const files = readdirSync('/home/ubuntu/cayce-trader').filter(f => f.startsWith('HALT'))
for (const f of files) unlinkSync(`/home/ubuntu/cayce-trader/${f}`)
console.log(`[trader] ${files.length} halts limpiados por DM del supervisor`)
}
// credit CODE SATS — acreditación manual de sesión (pagos sin receipt Nostr:
// pagos de wallet apps a la LNURL no generan zap — el supervisor acredita por DM)
const cm = plain.match(/credit\s+([A-F0-9]{6})\s+(\d+)\s*sats/i)
if (cm) {
const sessCode = cm[1].toUpperCase(), sats = Number(cm[2])
poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, sessCode])
.then(r => {
if (r.rowCount) {
poolPg.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)',
['yellow', 'manual_credit', { session: sessCode, sats, dm: evId.slice(0, 12) }, 'ok']).catch(() => { })
console.log(`[sesión ${sessCode}] +${sats} sats → crédito ${r.rows[0].credit_sats} — crédito manual por DM del supervisor`)
} else {
console.log(`[crédito manual] código ${sessCode} inexistente — ignorado`)
}
}).catch(e => console.error('[crédito manual]:', String(e).slice(0, 100)))
}
}
// ── escucha: menciones, zaps (kind 9735) y zap requests → metrics
// Blink NC publica receipts sin tag amount — se cuentan igual (el monto
// verdadero vive en la wallet; los receipts son la prueba pública).
// FIX 2026-10-08: nostr-tools 2.25.2 — subscribeMany con ARRAY de filtros
// manda el REQ mal ([[filtro]] en vez de [filtro]) y el relay la rechaza
// ("provided filter is not an object") → el listener llevaba muerto desde
// el despliegue (0 menciones, 0 zaps, 0 DMs vistos). Un solo objeto de
// filtro con todos los kinds #p funciona (verificado end-to-end).
const LISTEN_RELAYS = [...new Set([...RELAYS, 'wss://public.relaying.io', 'wss://relay.snort.social', 'wss://nostr-pub.wellorder.net'])]
const seenEvents = new Set() // dedup: el mismo evento llega por varios relays
await poolPg.query(`CREATE TABLE IF NOT EXISTS zaps_seen (event_id TEXT PRIMARY KEY, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())`).catch(() => { })
const listenSub = pool.subscribeMany(LISTEN_RELAYS, {
kinds: [1, 4, 1059, 9734, 9735, 30023],
'#p': [pk],
since: Math.floor(Date.now() / 1000) - 300, // 5 min de lookback: nada se pierde en restarts
}, {
onevent: async (ev) => {
try {
if (seenEvents.has(ev.id)) return
seenEvents.add(ev.id)
if (seenEvents.size > 20000) seenEvents.clear()
if (ev.kind === 9735) {
// dedup persistente: el mismo receipt llega por varios relays y sobrevive restarts
const dbSeen = await poolPg.query('INSERT INTO zaps_seen (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id', [ev.id])
if (dbSeen.rowCount === 0) return
const amountTag = ev.tags.find(t => t[0] === 'amount')
const msats = amountTag ? Number(amountTag[1] ?? 0) : null
await poolPg.query(
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['nostr', 'zaps_msats', msats ?? 0, `receipt ${ev.id.slice(0, 12)}${msats === null ? ' (sin amount tag)' : ''}`]
)
console.log(`[zap] receipt ${ev.id.slice(0, 12)} de ${ev.pubkey.slice(0, 8)} — ${msats !== null ? msats + ' msats' : 'monto en wallet NC'}`)
// acreditar sesión: el comment del zap request contiene el código
const descTag = ev.tags.find(t => t[0] === 'description')
if (descTag) {
try {
const zapReq = JSON.parse(descTag[1])
const comment = zapReq.content || ''
const m = comment.toUpperCase().match(/\b([A-F0-9]{6})\b/)
if (m) {
const sessionCode = m[1]
const amountSats = msats !== null && msats > 0 ? Math.round(msats / 1000) : 21
const upd = await poolPg.query(
'UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats',
[amountSats, sessionCode]
)
if (upd.rowCount > 0) {
console.log(`[sesión ${sessionCode}] +${amountSats} sats → crédito ${upd.rows[0].credit_sats}`)
await poolPg.query(
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', 'session_credit_sats', amountSats, sessionCode]
)
}
}
} catch (e) {
console.error('[sesión]', String(e).slice(0, 120))
}
}
} else if (ev.kind === 9734) {
console.log(`[zap request] de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 60)}`)
} else if (ev.kind === 1059) {
// NIP-17 gift wrap: desenvolver y atender comandos del supervisor
try {
const seal = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey)))
if (seal.kind !== 13) return
const chat = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(sk, seal.pubkey)))
if (chat.kind === 14 && chat.pubkey === SUPERVISOR) {
console.log(`[DM supervisor NIP-17] ${chat.content.slice(0, 120)}`)
handleSupervisorText(chat.content, ev.id)
}
} catch { /* gift wrap no dirigido a nosotros */ }
} else if (ev.kind === 4 && ev.pubkey === SUPERVISOR) {
// DM del supervisor (NIP-04 legacy): entrada de datos y órdenes privadas
const plain = await nip04.decrypt(sk, ev.pubkey, ev.content)
console.log(`[DM supervisor] ${plain.slice(0, 120)}`)
handleSupervisorText(plain, ev.id)
await poolPg.query(
'INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)',
['yellow', 'supervisor_message', { content: plain.slice(0, 500), event_id: ev.id }]
)
} else if (ev.kind === 4) {
console.log(`[DM] de ${ev.pubkey.slice(0, 8)} — no es supervisor, ignorado`)
} else {
console.log(`[mención] kind ${ev.kind} de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 80)}`)
if (ev.kind === 1) await maybeReplyToMention(ev)
}
} catch (e) {
console.error('[listener]', String(e))
}
},
// suscripción muerta = sordo: que se vea en el journal (la retro lo escala)
onclose: (reasons) => console.error('[listener] suscripción cerrada por los relays:', JSON.stringify(reasons).slice(0, 250)),
})
// ── HTTP
const server = http.createServer(async (req, res) => {
const json = (status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(obj))
}
if (req.method === 'GET' && req.url === '/healthz') return json(200, { status: 'ok', pubkey: pk })
if (req.method === 'POST' && req.url === '/publish') {
let body = ''
req.on('data', c => (body += c))
req.on('end', async () => {
try {
const { kind, content, title, tags, type } = JSON.parse(body || '{}')
if (!content) return json(400, { error: 'content requerido' })
const t = [...(tags || [])]
if (kind === 30023) {
t.push(['title', title || content.slice(0, 60)])
t.push(['published_at', String(Math.floor(Date.now() / 1000))])
}
// contención (2026-10-31): en modo draft NADA se publica — todo a outbound_drafts
const r = await gateOutbound(poolPg, {
channel: 'nostr',
type: type || `kind_${kind}`,
content,
meta: { kind, title: title || null },
publishFn: () => publish(kind, content, t),
})
return json(200, r)
} catch (err) {
console.error('[publish]', String(err))
return json(500, { error: String(err).slice(0, 200) })
}
})
return
}
if (req.method === 'POST' && req.url === '/dm') {
let body = ''
req.on('data', c => (body += c))
req.on('end', async () => {
try {
const { to_hex, content } = JSON.parse(body || '{}')
if (!to_hex || !content) return json(400, { error: 'to_hex + content' })
// NIP-17: kind 14 → seal (13) → gift wrap (1059)
const now = Math.floor(Date.now() / 1000)
const chat = finalizeEvent({ kind: 14, created_at: now, tags: [['p', to_hex]], content }, sk)
const seal = finalizeEvent({ kind: 13, created_at: now - Math.floor(Math.random() * 200), tags: [], content: nip44.encrypt(JSON.stringify(chat), nip44.getConversationKey(sk, to_hex)) }, sk)
const randSk = generateSecretKey()
const wrap = finalizeEvent({ kind: 1059, created_at: now, tags: [['p', to_hex]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(randSk, to_hex)) }, randSk)
const dmPool = new SimplePool()
await Promise.allSettled(dmPool.publish(RELAYS, wrap))
// el accounting de pool.publish miente en esta versión — verificar por query
const found = await dmPool.querySync(RELAYS, { ids: [wrap.id] }, 6000).catch(() => [])
const ok = found.length
setTimeout(() => { try { dmPool.close(RELAYS) } catch { } }, 30000)
console.log(`[dm] NIP-17 enviado a ${to_hex.slice(0, 8)} → verificado en ${ok} relays`)
return json(200, { id: wrap.id, relays: ok })
} catch (err) {
return json(500, { error: String(err).slice(0, 200) })
}
})
return
}
if (req.method === 'POST' && req.url === '/follows') {
// actualiza el contact list (kind 3) — llamado por cayce-editorials (discovery)
let body = ''
req.on('data', c => (body += c))
req.on('end', async () => {
try {
const { pubkeys } = JSON.parse(body || '{}')
if (!Array.isArray(pubkeys) || !pubkeys.length) return json(400, { error: 'pubkeys[] requerido' })
const ev = finalizeEvent({
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]),
content: '',
}, sk)
await pool.publish(RELAYS, ev)
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
return json(200, { id: ev.id, follows: pubkeys.length })
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
})
return
}
json(404, { error: 'not found' })
})
server.listen(PORT, '127.0.0.1', () => {
console.log(`[cayce-nostr] listening on 127.0.0.1:${PORT}`)
})