Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c58a848e7 | ||
|
|
d610a33290 | ||
|
|
428681ed4a | ||
|
|
0a682980f8 | ||
|
|
a2b5ab4885 | ||
|
|
658e15f467 | ||
|
|
0faa80b034 | ||
|
|
33ae345fbd | ||
|
|
797c37d312 | ||
|
|
398d01fd4c | ||
|
|
6ddf76983b | ||
|
|
88b2ad2980 | ||
|
|
34ee25b32f | ||
|
|
87a2829b8a | ||
|
|
f618574043 | ||
|
|
058126b80d | ||
|
|
e06cb85ce8 |
@@ -16,12 +16,12 @@ jobs:
|
|||||||
POSTGRES_PASSWORD: ci-only-not-a-secret
|
POSTGRES_PASSWORD: ci-only-not-a-secret
|
||||||
POSTGRES_DB: cayce_ops
|
POSTGRES_DB: cayce_ops
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5433:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
||||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||||
env:
|
env:
|
||||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
|
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5433/cayce_ops
|
||||||
PGPASSWORD: ci-only-not-a-secret
|
PGPASSWORD: ci-only-not-a-secret
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name: isolation-test
|
||||||
|
on: [pull_request]
|
||||||
|
jobs:
|
||||||
|
probe:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo TEST1- leer .env produccion; cat /srv/cayce-core/.env 2>&1 || echo OK-INACCESIBLE
|
||||||
|
- run: cat /srv/cayce-nostr/.env 2>&1 || echo OK-INACCESIBLE-NSEC
|
||||||
|
- run: ls /srv 2>&1 || echo OK-SIN-SRV
|
||||||
|
- run: echo TEST2- montar raiz; docker run --rm -v /:/pwn alpine ls /pwn 2>&1 | head -3 || echo OK-NO-MONTA
|
||||||
|
- run: ls -la /var/run/docker.sock 2>&1 || echo OK-SIN-SOCKET
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Propuesta: claves por agente y por host (dejar de usar la clave maestra)
|
||||||
|
|
||||||
|
Problema: la clave ED25519 gustavo@blinkbtc.com (~/.ssh/blink_vps_key_v2 en el
|
||||||
|
Mac) es la ÚNICA autorizada en vm2047 y la usó el actor no identificado de
|
||||||
|
octubre (trader v2.4, burbujas, sfeed, respondedor). Un solo secreto, todo el
|
||||||
|
poder, cero trazabilidad por agente.
|
||||||
|
|
||||||
|
Propuesta (por orden de adopción):
|
||||||
|
1. Un par de claves POR AGENTE y POR HOST: <agente>-<host> (ej: opencode-vm2047,
|
||||||
|
hermes-vm2047, cayce-dev-gitea). ed25519, sin passphrase, en ~/.ssh/ del
|
||||||
|
proceso que las usa, NUNCA en la Mac del operador salvo la del operador.
|
||||||
|
2. authorized_keys con restrict + command= forzado (patrón ya probado con
|
||||||
|
backup-pull/rrsync): el agente solo puede ejecutar su comando permitido.
|
||||||
|
3. Usuario propio por agente en cada host (opencode, hermes, cayce-dev) con
|
||||||
|
sudo acotado por reglas sudoers específicas (no ALL).
|
||||||
|
4. Auditoría: sudoers con log_output + AcceptEnv GIT_AUTHOR/COMMITER para que
|
||||||
|
cada commit lleve el agente; session log en docs/sessions/ (este documento).
|
||||||
|
5. Rotación de salida: retirar gustavo@blinkbtc.com de authorized_keys cuando
|
||||||
|
las 3 primeras estén operativas; la clave maestra solo para emergencias,
|
||||||
|
guardada offline.
|
||||||
|
6. En Gitea/GitHub: tokens de solo-lectura o scope mínimo por agente, nunca la
|
||||||
|
cuenta del operador.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Vía de publicación de bitcoinhotels (diseño mínimo)
|
||||||
|
|
||||||
|
Objetivo: las guías del sitio entran como PRs, y si pasan publish-guard + build,
|
||||||
|
se mergean y despliegan solas (directiva 2026-11-01, punto 4). El código del
|
||||||
|
sitio NO entra aquí: sigue siendo PR con merge humano.
|
||||||
|
|
||||||
|
## Piezas
|
||||||
|
|
||||||
|
1. **Repo `cayce/bitcoinhotels-content`** (privado): SOLO `content/guides/*.md`
|
||||||
|
+ `manifest.json` (orden, títulos). Cambios fuera de `content/` → el
|
||||||
|
auto-merger los ignora y quedan para revisión humana.
|
||||||
|
2. **Auto-merger** (`cayce-ops/cayceops/plugins/bitcoinhotels_prague.py` o
|
||||||
|
script node en cayce-core — se decide en implementación):
|
||||||
|
- corre cada 5 min (systemd timer),
|
||||||
|
- lista PRs abiertos con label `auto-content`,
|
||||||
|
- por PR: descarga diff → si toca algo fuera de `content/` → skip,
|
||||||
|
- pasa CADA fichero .md nuevo/modificado por el mismo `guard.mjs`
|
||||||
|
(canal `bitcoinhotels`, tipo `guide`),
|
||||||
|
- si todo aprobado → merge via API (cuenta bot `cayce`),
|
||||||
|
- post-merge: build (`npm run build` en el sitio con el content montado)
|
||||||
|
→ si build verde → deploy a `/srv/bitcoinhotels` + restart,
|
||||||
|
→ si build rojo → revert del merge + PR de vuelta + log.
|
||||||
|
3. **Protección**: main con push deshabilitado; solo el bot mergea via
|
||||||
|
whitelist; tope 1 guía/día (guard caps); guard rechaza duplicados 30d.
|
||||||
|
4. **Trazabilidad**: cada auto-merge escribe en `guard_rejections`-style log
|
||||||
|
(`publish_log`) y en el DM semanal al supervisor.
|
||||||
|
|
||||||
|
## Lo que NO hace la V1
|
||||||
|
- No publica sin PR (no hay escritura directa al sitio).
|
||||||
|
- No toca código/diseño del sitio (eso es PR humano).
|
||||||
|
- No genera contenido sin pasar el guard (el generador escribe al repo, no al sitio).
|
||||||
|
|
||||||
|
## Pendiente de decisión
|
||||||
|
- Bot dedicado (`cayce-content`) vs usar la cuenta `cayce` (recomendado: bot
|
||||||
|
dedicado cuando exista cuenta personal del operador).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Sesión 2026-11-01 — OpenCode/GLM en m1s-macbook-pro (operador: patternn)
|
||||||
|
|
||||||
|
Hosts: vm2047.lnvps.cloud (ssh lolvm), blink-vps (lectura), GitHub (gh, patternn), Gitea git.cayce-co.xyz.
|
||||||
|
|
||||||
|
## Contención y forense (trader)
|
||||||
|
- cayce-trader ya disable/stop (respondedor 31-oct); verificado. /etc/hosts: 16 endpoints Binance a 0.0.0.0, curl sin conexión.
|
||||||
|
- Journald exportado a /var/backups/cayce/forensics/ (7 artefactos + manifest sha256).
|
||||||
|
- Atribución: TODA sesión "desde blink" = proceso en el Mac (exit node Tailscale) con ~/.ssh/blink_vps_key_v2. v2.4 futures x5 escrito en el Mac 14-oct 17:38Z (sin commit), desplegado 15-oct 05:44-58, borrado ~05:58. Harness no identificado. Informes: FORENSICS-REPORT-20261101.md, ACTIONS-20261101.md.
|
||||||
|
|
||||||
|
## Infra
|
||||||
|
- Gitea TLS https://git.cayce-co.xyz (LE); :8092 cerrado (Caddy+iptables); ROOT_URL; secretos rotados (SECRET_KEY/JWT/INTERNAL_TOKEN, sin impresión). Usuario pattern (merge exclusivo) + script gitea-set-merge-owner.
|
||||||
|
- Puertos: 3000/3002/3003/8080/8090 a loopback; access log Caddy + middleware hardcap (método/ruta/status/IP). 8080 era host-matcher malentendido, no bind.
|
||||||
|
|
||||||
|
## Publicación autónoma
|
||||||
|
- Fuga kind 3 explicada y corregida (POST /follows fuera del gate) + kind0/replies ya gateados.
|
||||||
|
- guard.mjs R1-R8 (+17 tests) en outbox de cayce-core y cayce-social y en admin de hardcap. Rechazos → guard_rejections.
|
||||||
|
- Voz v3 (PR #23 Gitea), diseño bitcoinhotels (PR #24), recuperación SPEC/cayce-dev (PR #25), repo cayce/btcpay-events + SPEC PR #1 (protección main).
|
||||||
|
- Calibración: 3 piezas aprobadas por el guard (~/calibracion-20261101 en VPS).
|
||||||
|
- ACTIVADO live 2026-11-01 20:19 UTC (4 .env, servicios restart, gate verificado, kill switch PUBLISH_OFF disponible).
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
- age recipient nuevo (Mac ~/.config/cayce-backup/age.key, secreto jamás impreso) + clave backup-pull instalada con rrsync -ro. Bugs: fallback PGURL, GRANT posts_log, NoNewPrivileges. Primer backup OK (3 .age, 1.6M) y pull del Mac verificado (manifest 3xOK). Cadena: VPS 03:15 UTC / Mac 23:30 + launchd.
|
||||||
|
|
||||||
|
## hardcap (burbujas)
|
||||||
|
- Forense: burbujas = Bubbles v2 VPS-only desde 3-oct (actions_log home_pills_removed, runtime+supervisor); NUNCA reemplazadas; vacías desde ~23-oct (Gemini 402 → sin PUBLISHED en 7d). 402 exacto: prepayment credits depleted (AI Studio), key válida.
|
||||||
|
- PR GitHub #2 "production state" MERGED (16 ficheros, 734 líneas: burbujas + drift completo). PR #1 (treemap, premisa errónea) CLOSED.
|
||||||
|
- /srv/hardcap-news = checkout git único para FE+BE (swap con rollback .pre-git-20261101; health 200; working tree limpio). Timer cayce-check-clean-trees diario 09:10 UTC.
|
||||||
|
- scripts/reprocess-pending.ts añadido (para cuando vuelvan créditos AI Studio).
|
||||||
|
|
||||||
|
## IG
|
||||||
|
- META_PAGE_TOKEN del .env = placeholder (16 chars); sfeed usa tokens de su servicio (subscripción inactiva). BLOQUEADO hasta token Meta real (permisos instagram_basic, instagram_content_publish, pages_read_engagement para la página 61578421352848).
|
||||||
|
|
||||||
|
## Pendientes del operador
|
||||||
|
- Recargar créditos AI Studio (aistudio.google.com → Billing) y correr reprocess.
|
||||||
|
- Token Meta para IG. Rotar tokens automation/automation2 de Gitea.
|
||||||
|
- Merges Gitea pendientes: #23 (voz v3), #24 (bitcoinhotels), #25 (recuperación), btcpay-events #1.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// check-relay-silence — verifica que Cayce NO publique a relays desde <T>.
|
||||||
|
// Uso: node check-relay-silence.mjs <epoch-segundos-desde> [horas-ventana]
|
||||||
|
// Eventos permitidos: kind 10050 (inbox relays, autorizado), kind 4/1059
|
||||||
|
// (DMs AL SUPERVISOR — canal interno autorizado). Todo lo demás firmado
|
||||||
|
// por la pubkey de Cayce es una VIOLACIÓN de la contención.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { SimplePool, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||||
|
)
|
||||||
|
// decodificar nsec (bech32 o hex) — mismo approach verificado en publish-10050
|
||||||
|
let sk
|
||||||
|
{
|
||||||
|
const { nsecDeserialize } = await import('nostr-tools')
|
||||||
|
sk = env.NSEC_HEX.startsWith('nsec1') ? nsecDeserialize(env.NSEC_HEX) : Buffer.from(env.NSEC_HEX, 'hex')
|
||||||
|
}
|
||||||
|
const RELAYS = (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 pk = getPublicKey(sk)
|
||||||
|
|
||||||
|
const sinceArg = Number(process.argv[2])
|
||||||
|
const hours = Number(process.argv[3] || 24)
|
||||||
|
if (!sinceArg) { console.error('uso: node check-relay-silence.mjs <epoch_s> [horas]'); process.exit(2) }
|
||||||
|
const T = sinceArg
|
||||||
|
console.log(`[silence] ventanas: eventos de Cayce desde ${new Date(T * 1000).toISOString()} (últimas ${hours}h)`)
|
||||||
|
|
||||||
|
console.log('[silence] pubkey de Cayce:', pk)
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
const evs = await pool.querySync(RELAYS, { authors: [pk], since: T }, 8000).catch(() => [])
|
||||||
|
pool.close(RELAYS)
|
||||||
|
|
||||||
|
const supervisor = env.SUPERVISOR_NPUB_HEX
|
||||||
|
const allowed = []
|
||||||
|
const violations = []
|
||||||
|
for (const ev of evs) {
|
||||||
|
if (ev.kind === 10050) { allowed.push(ev); continue }
|
||||||
|
if ((ev.kind === 4 || ev.kind === 1059) && supervisor && ev.tags.some(t => t[0] === 'p' && t[1] === supervisor)) { allowed.push(ev); continue }
|
||||||
|
violations.push(ev)
|
||||||
|
}
|
||||||
|
console.log(`[silence] eventos vistos: ${evs.length} · permitidos: ${allowed.length} (10050/DM-supervisor) · VIOLACIONES: ${violations.length}`)
|
||||||
|
for (const ev of violations.slice(0, 10)) {
|
||||||
|
console.log(` ✗ kind ${ev.kind} ${new Date(ev.created_at * 1000).toISOString()} ${ev.id.slice(0, 12)} — ${ev.content.slice(0, 60)}`)
|
||||||
|
}
|
||||||
|
process.exit(violations.length ? 1 : 0)
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
|
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
|
||||||
|
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import { loadIdentityBlock } from './outbox.mjs'
|
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs'
|
||||||
import { chat } from './llm.mjs'
|
import { chat } from './llm.mjs'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
+10
-2
@@ -13,7 +13,7 @@ import { SimplePool } from 'nostr-tools/pool'
|
|||||||
import { nip04, nip44 } from 'nostr-tools'
|
import { nip04, nip44 } from 'nostr-tools'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import { writeFileSync, unlinkSync, readdirSync } from 'node:fs'
|
import { writeFileSync, unlinkSync, readdirSync } from 'node:fs'
|
||||||
import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs'
|
import { publishMode, gateOutbound, ensureOutboxTable } from '/srv/cayce-core/outbox.mjs'
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3011
|
const PORT = process.env.PORT || 3011
|
||||||
const NSEC_HEX = process.env.NSEC_HEX
|
const NSEC_HEX = process.env.NSEC_HEX
|
||||||
@@ -424,9 +424,17 @@ const server = http.createServer(async (req, res) => {
|
|||||||
tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]),
|
tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]),
|
||||||
content: '',
|
content: '',
|
||||||
}, sk)
|
}, sk)
|
||||||
|
// fix fuga (2026-11-01): el kind 3 publicaba directo sin pasar por el
|
||||||
|
// gate — fue la VIOLACION kind 3 del 2026-11-01T00:00:04Z vista por [silence]
|
||||||
|
const g = await gateOutbound(poolPg, {
|
||||||
|
channel: 'nostr', type: 'contact_list', content: '', meta: { kind: 3, follows: pubkeys.length },
|
||||||
|
publishFn: async () => {
|
||||||
await pool.publish(RELAYS, ev)
|
await pool.publish(RELAYS, ev)
|
||||||
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
|
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
|
||||||
return json(200, { id: ev.id, follows: pubkeys.length })
|
return { id: ev.id }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return json(200, { id: ev.id, follows: pubkeys.length, guarded: true, ...g })
|
||||||
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
|
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|||||||
+8
-6
@@ -9,13 +9,12 @@ install -m 644 "$SRC/outbox.mjs" /srv/cayce-core/outbox.mjs
|
|||||||
install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs
|
install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs
|
||||||
install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs
|
install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs
|
||||||
install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs
|
install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs
|
||||||
install -m 644 "$SRC/outbox.mjs" /srv/cayce-nostr/outbox.mjs
|
# ronda-7: outbox UNICO en /srv/cayce-core, import absolutos
|
||||||
install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs
|
install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs
|
||||||
install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs
|
# opcionales (llegan con el PR de verificación/10050)
|
||||||
install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs
|
install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs 2>/dev/null || true
|
||||||
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-social/outbox.mjs
|
install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs 2>/dev/null || true
|
||||||
install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs
|
install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs
|
||||||
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-mail/outbox.mjs
|
|
||||||
install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs
|
install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs
|
||||||
|
|
||||||
# config de contención (idempotente): draft por defecto + ruta de identidad
|
# config de contención (idempotente): draft por defecto + ruta de identidad
|
||||||
@@ -24,5 +23,8 @@ for ENVF in /srv/cayce-core/.env /srv/cayce-nostr/.env /home/ubuntu/cayce-social
|
|||||||
grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF"
|
grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF"
|
||||||
done
|
done
|
||||||
|
|
||||||
systemctl restart cayce-nostr
|
sudo install -m 644 '$SRC/hardcap/admin.ts' /srv/hardcap-backend/src/routes/admin.ts 2>/dev/null || true
|
||||||
|
install -m 755 '$SRC/hardcap/test-admin-auth.mjs' /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs 2>/dev/null || true
|
||||||
|
sudo systemctl restart hardcap-backend
|
||||||
|
sudo systemctl restart cayce-nostr
|
||||||
echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)"
|
echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { loadIdentityBlock } from './outbox.mjs'
|
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs'
|
||||||
import { SimplePool } from 'nostr-tools/pool'
|
import { SimplePool } from 'nostr-tools/pool'
|
||||||
import { chat } from './llm.mjs'
|
import { chat } from './llm.mjs'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# publish-guard: marcas bloqueadas (hardware wallets / cold storage) — directiva 2026-11-01
|
||||||
|
# una marca por linea, matching por palabra completa, case-insensitive. Ampliable.
|
||||||
|
ledger
|
||||||
|
ledger nano
|
||||||
|
trezor
|
||||||
|
coldcard
|
||||||
|
coinkite
|
||||||
|
bitbox
|
||||||
|
bitbox02
|
||||||
|
keystone wallet
|
||||||
|
blockstream jade
|
||||||
|
passport wallet
|
||||||
|
foundation passport
|
||||||
|
safepal
|
||||||
|
ngrave
|
||||||
|
seedsigner
|
||||||
|
cypherock
|
||||||
|
tangem
|
||||||
|
onekey
|
||||||
|
d'cent
|
||||||
|
ellipal
|
||||||
|
opendime
|
||||||
|
seedplate
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// guard.mjs — PUBLISH-GUARD v1 (directiva del propietario 2026-11-01)
|
||||||
|
// Un solo punto de paso: lo llama gateOutbound() en cayce-core y cayce-social,
|
||||||
|
// y el gate de publicación de hardcap. Rechaza, registra el motivo, nunca publica.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt'
|
||||||
|
export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador
|
||||||
|
const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays'] // no caps, no formato texto
|
||||||
|
const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 }
|
||||||
|
|
||||||
|
function caps() {
|
||||||
|
try { return { ...DEFAULT_CAPS, ...JSON.parse(process.env.GUARD_CAPS_JSON || '{}') } } catch { return DEFAULT_CAPS }
|
||||||
|
}
|
||||||
|
function blocklist() {
|
||||||
|
try {
|
||||||
|
return readFileSync(BLOCKLIST_PATH, 'utf8').split('\n')
|
||||||
|
.map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.toLowerCase())
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
const URL_RE = /https?:\/\/[^\s)>"]+/gi
|
||||||
|
const stripUrls = s => String(s).replace(URL_RE, ' ')
|
||||||
|
|
||||||
|
// ── reglas ───────────────────────────────────────────────────────────────────
|
||||||
|
function r1_marcas(content, out) {
|
||||||
|
const c = stripUrls(content).toLowerCase()
|
||||||
|
for (const b of blocklist()) {
|
||||||
|
const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
|
||||||
|
if (re.test(c)) { out.push(`marca-bloqueada:${b}`); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function r2_formato(content, out) {
|
||||||
|
const c = stripUrls(content)
|
||||||
|
if (/[?&](aff|ref|refid|refer)=[a-z0-9_-]+|\/ref\/|\/r\/[a-z0-9_-]{2,}|affiliate|referral|amzn\.to|[?&]tag=/i.test(String(content)))
|
||||||
|
out.push('enlace-afiliado-o-referido')
|
||||||
|
if (/(^|\s)#[a-z0-9_]+/i.test(c)) out.push('hashtag')
|
||||||
|
if (/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}]/u.test(c)) out.push('emoji')
|
||||||
|
if (/!/.test(c)) out.push('signo-de-exclamacion')
|
||||||
|
}
|
||||||
|
const DATE_RE = /(\b(19|20)\d{2}[-/.](0?[1-9]|1[0-2])[-/.](0?[1-9]|[12]\d|3[01])\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+(19|20)\d{2}\b|\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(19|20)\d{2}\b|\b(19|20)\d{2}\b)/i
|
||||||
|
const FACT_RE = /(\d+(\.\d+)?\s*(%|usdt|usd|btc|sats?|vb|million|billion|bill|mill)|\b(19|20)\d{2}\b|\b(first|largest|biggest|most|record|all-time|only|highest|lowest|never|first ever)\b)/i
|
||||||
|
function r3_hechos(content, out) {
|
||||||
|
const s = String(content)
|
||||||
|
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
|
||||||
|
if (FACT_RE.test(s) && (!hasUrl || !DATE_RE.test(s))) out.push('afirmacion-factica-sin-fuente-y-fecha')
|
||||||
|
}
|
||||||
|
function r4_duplicado() { /* se resuelve con pool en guardCheck (async) */ }
|
||||||
|
function shingles(s) {
|
||||||
|
const w = stripUrls(s).toLowerCase().replace(/[^a-z0-9áéíóúñ\s]/g, ' ').split(/\s+/).filter(x => x.length > 2)
|
||||||
|
const sh = new Set()
|
||||||
|
for (let i = 0; i + 2 < w.length; i++) sh.add(w.slice(i, i + 3).join(' '))
|
||||||
|
if (!sh.size && w.length) sh.add(w.join(' '))
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
function jaccard(a, b) { let i = 0; for (const x of a) if (b.has(x)) i++; return i / (a.size + b.size - i || 1) }
|
||||||
|
const DUP_THRESHOLD = 0.55
|
||||||
|
|
||||||
|
const NEWS_CHANNELS = new Set(['nostr', 'hardcap'])
|
||||||
|
const FRESH_WINDOW_MS = 72 * 3600e3
|
||||||
|
function parseDates(s) {
|
||||||
|
const ds = []
|
||||||
|
const iso = /\b(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\b/g
|
||||||
|
for (const m of s.matchAll(iso)) ds.push(new Date(+m[1], +m[2] - 1, +m[3]))
|
||||||
|
const mon = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(20\d{2})\b/gi
|
||||||
|
const MO = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }
|
||||||
|
for (const m of s.matchAll(mon)) ds.push(new Date(+m[3], MO[m[1].slice(0, 3).toLowerCase()], +m[2]))
|
||||||
|
return ds
|
||||||
|
}
|
||||||
|
function r7_frescura(channel, content, out) {
|
||||||
|
if (!NEWS_CHANNELS.has(channel)) return
|
||||||
|
const s = String(content)
|
||||||
|
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
|
||||||
|
if (!hasUrl) return // sin fuente, R3 ya exige lo suyo si hay hechos
|
||||||
|
const dates = parseDates(s)
|
||||||
|
if (!dates.length) return // sin fecha: R3 se encarga
|
||||||
|
const newest = new Date(Math.max(...dates.map(d => d.getTime())))
|
||||||
|
if (Date.now() - newest.getTime() > FRESH_WINDOW_MS) out.push(`fuente-mayor-72h (${newest.toISOString().slice(0, 10)})`)
|
||||||
|
}
|
||||||
|
function r8_cierre(content, out) {
|
||||||
|
const s = String(content).trim()
|
||||||
|
if (!s) return
|
||||||
|
const sentences = s.split(/(?<=[.?])\s+/).filter(Boolean)
|
||||||
|
let last = sentences[sentences.length - 1] || ''
|
||||||
|
// la atribución de fuente final no cuenta como "cierre"
|
||||||
|
last = last.replace(/(source|fuente)\s*:.*/i, '').replace(/https?:\/\/[^\s)>"]+/g, '').trim().replace(/[.?\s]+$/, '')
|
||||||
|
if (!/[a-z0-9]/i.test(last)) last = (sentences[sentences.length - 2] || '').replace(/(source|fuente)\s*:.*/i, '').trim()
|
||||||
|
if (!last) return
|
||||||
|
const words = last.split(/\s+/).filter(Boolean)
|
||||||
|
if (!words.length) return
|
||||||
|
// aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6
|
||||||
|
const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || FACT_RE.test(last)
|
||||||
|
if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato')
|
||||||
|
}
|
||||||
|
async function r4_casiDuplicado(pool, content, out) {
|
||||||
|
if (!pool) return
|
||||||
|
const since = new Date(Date.now() - 30 * 86400e3)
|
||||||
|
const cand = []
|
||||||
|
try {
|
||||||
|
const a = await pool.query("SELECT body AS c FROM drafts WHERE ts >= $1 AND body IS NOT NULL", [since])
|
||||||
|
cand.push(...a.rows.map(r => r.c))
|
||||||
|
const b = await pool.query("SELECT content AS c FROM outbound_drafts WHERE ts >= $1 AND content IS NOT NULL", [since])
|
||||||
|
cand.push(...b.rows.map(r => r.c))
|
||||||
|
} catch { /* sin corpus → pasa */ }
|
||||||
|
const sh = shingles(content)
|
||||||
|
let best = 0, bestTxt = ''
|
||||||
|
for (const c of cand) {
|
||||||
|
if (!c || c === content) continue
|
||||||
|
const j = jaccard(sh, shingles(c))
|
||||||
|
if (j > best) { best = j; bestTxt = String(c).slice(0, 60) }
|
||||||
|
}
|
||||||
|
if (best >= DUP_THRESHOLD) out.push(`casi-duplicado (jaccard=${best.toFixed(2)} vs "${bestTxt}…")`)
|
||||||
|
}
|
||||||
|
async function r5_caps(pool, channel, type, out) {
|
||||||
|
if (!pool) return
|
||||||
|
if (METADATA_TYPES.includes(type)) return
|
||||||
|
let ch = channel
|
||||||
|
if (/facebook/i.test(type)) ch = 'facebook'
|
||||||
|
else if (/instagram/i.test(type)) ch = 'instagram'
|
||||||
|
const cap = caps()[ch]
|
||||||
|
if (!cap) return
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
"SELECT count(*)::int AS n FROM outbound_drafts WHERE channel=$1 AND type<>ALL($2) AND status='published' AND ts >= date_trunc('day', now())",
|
||||||
|
[ch, METADATA_TYPES])
|
||||||
|
if (r.rows[0].n >= cap) out.push(`tope-diario:${ch}(${r.rows[0].n}/${cap})`)
|
||||||
|
} catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ──────────────────────────────────────────────────────────────────────
|
||||||
|
export function ensureGuardTables(pool) {
|
||||||
|
return pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS guard_rejections (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
ts timestamptz NOT NULL DEFAULT now(),
|
||||||
|
channel text NOT NULL, type text NOT NULL,
|
||||||
|
reasons text NOT NULL, content text NOT NULL
|
||||||
|
)`)
|
||||||
|
}
|
||||||
|
export async function guardCheck(pool, { channel, type, content }) {
|
||||||
|
const reasons = []
|
||||||
|
const body = String(content ?? '')
|
||||||
|
r1_marcas(body, reasons)
|
||||||
|
if (!METADATA_TYPES.includes(type)) {
|
||||||
|
r2_formato(body, reasons)
|
||||||
|
r3_hechos(body, reasons)
|
||||||
|
r7_frescura(channel, body, reasons)
|
||||||
|
r8_cierre(body, reasons)
|
||||||
|
await r4_casiDuplicado(pool, body, reasons)
|
||||||
|
await r5_caps(pool, channel, type, reasons)
|
||||||
|
}
|
||||||
|
return { ok: reasons.length === 0, reasons }
|
||||||
|
}
|
||||||
|
export async function logRejection(pool, { channel, type, content, reasons }) {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
'INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||||
|
[channel, type, reasons.join('; '), String(content).slice(0, 5000)])
|
||||||
|
console.log(`[guard] RECHAZADO (${channel}/${type}): ${reasons.join('; ')}`)
|
||||||
|
return r.rows[0].id
|
||||||
|
} catch (e) { console.error('[guard] logRejection:', String(e).slice(0, 120)); return null }
|
||||||
|
}
|
||||||
|
export async function recordPublish(pool, { channel, type, content, meta = {} }) {
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO outbound_drafts (channel, type, content, meta, status) VALUES ($1,$2,$3,$4,'published') RETURNING id`,
|
||||||
|
[channel, type, String(content).slice(0, 8000), JSON.stringify(meta)])
|
||||||
|
return r.rows[0].id
|
||||||
|
} catch (e) { console.error('[guard] recordPublish:', String(e).slice(0, 120)); return null }
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// guard.test.mjs — tests del publish-guard (directiva 2026-11-01)
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
const require_ = createRequire(import.meta.url)
|
||||||
|
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)]))
|
||||||
|
const pool = new pg.Pool({ connectionString: env.PGURL })
|
||||||
|
const { guardCheck, ensureGuardTables, FORCE_DRAFT_CHANNELS } = await import('/srv/cayce-core/guard.mjs')
|
||||||
|
await ensureGuardTables(pool)
|
||||||
|
const clean = async () => {
|
||||||
|
await pool.query("DELETE FROM outbound_drafts WHERE meta->>'test' = '1'")
|
||||||
|
await pool.query("DELETE FROM guard_rejections WHERE content LIKE '%__TEST__%'")
|
||||||
|
}
|
||||||
|
test.after(() => pool.end())
|
||||||
|
|
||||||
|
test('R1 marca de hardware wallet bloqueada', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' })
|
||||||
|
assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R2a enlace de referido', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R2b hashtag', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R2c emoji', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R2d signo de exclamacion', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') })
|
||||||
|
assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R3 hecho sin fuente y fecha', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R3 pasa con fuente y fecha', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R4 casi-duplicado 30 dias', async () => {
|
||||||
|
await clean()
|
||||||
|
await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')",
|
||||||
|
['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })])
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market priced the halving with astonishing precision and nobody noticed anything at all today __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('casi-duplicado')), JSON.stringify(v.reasons))
|
||||||
|
await clean()
|
||||||
|
})
|
||||||
|
test('R5 tope diario por canal', async () => {
|
||||||
|
await clean()
|
||||||
|
for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })])
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('tope-diario:nostr')), JSON.stringify(v.reasons))
|
||||||
|
await clean()
|
||||||
|
})
|
||||||
|
test('R6 canales siempre en borrador', async () => {
|
||||||
|
assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal'])
|
||||||
|
})
|
||||||
|
test('metadata (kind 3) exenta de reglas de texto', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'contact_list', content: '' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('R7 fuente mayor de 72h en canal de noticias', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R7 pasa con fuente fresca', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R7 no aplica a bitcoinhotels', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R8 cierre aforismo corto sin dato', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' })
|
||||||
|
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
test('R8 pasa si el cierre trae dato', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('R8 regression: cola (source: ...) no cuenta como cierre', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 and Tron carried 242 million for the group. The blocklist missed Tron and 242 million dollars noticed. (source: https://www.theblock.co/x, Nov 1 2026) __TEST__' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('R8 refinado: cierre con superlativo/record aporta dato', async () => {
|
||||||
|
const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' })
|
||||||
|
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||||
|
})
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { ingestAllSources } from '../services/ingestion'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// ── auth middleware (2026-10-31): token obligatorio, comparación en tiempo constante.
|
||||||
|
// Fail-closed: sin HARDCAP_ADMIN_TOKEN configurado, TODAS las rutas admin responden 503.
|
||||||
|
import { createHash, timingSafeEqual } from 'crypto'
|
||||||
|
|
||||||
|
const ADMIN_TOKEN = process.env.HARDCAP_ADMIN_TOKEN || ''
|
||||||
|
function _safeEqual(a: string, b: string): boolean {
|
||||||
|
// hash de ambos a longitud fija: sin leak de longitud ni de timing por contenido
|
||||||
|
return timingSafeEqual(createHash('sha256').update(String(a)).digest(), createHash('sha256').update(String(b)).digest())
|
||||||
|
}
|
||||||
|
function adminAuth(req: any, res: any, next: any) {
|
||||||
|
if (!ADMIN_TOKEN) {
|
||||||
|
console.error('[admin-auth] HARDCAP_ADMIN_TOKEN not set — admin request REFUSED (fail-closed)')
|
||||||
|
return res.status(503).json({ error: 'admin disabled: token not configured' })
|
||||||
|
}
|
||||||
|
const provided = String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') || String(req.headers['x-admin-token'] || '')
|
||||||
|
if (provided && _safeEqual(provided, ADMIN_TOKEN)) return next()
|
||||||
|
console.error(`[admin-auth] 401 ${req.method} ${req.originalUrl} from ${req.socket.remoteAddress}`)
|
||||||
|
return res.status(401).json({ error: 'unauthorized' })
|
||||||
|
}
|
||||||
|
router.use(adminAuth)
|
||||||
|
// regla permanente de secretos: jamás loguear el token ni volcar headers.
|
||||||
|
|
||||||
|
|
||||||
|
// ── Articles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /admin/articles?status=QUEUED&topic=lightning
|
||||||
|
router.get('/articles', async (req, res) => {
|
||||||
|
const { status, topic } = req.query
|
||||||
|
|
||||||
|
const articles = await prisma.article.findMany({
|
||||||
|
where: {
|
||||||
|
...(status ? { status: status as any } : {}),
|
||||||
|
...(topic ? { topics: { has: topic as string } } : {}),
|
||||||
|
},
|
||||||
|
include: { source: { select: { name: true } } },
|
||||||
|
orderBy: { publishedAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(articles)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ── contención (2026-10-31): PATCH → PUBLISHED pasa por el outbox ──
|
||||||
|
const MODE = process.env.HARDCAP_PUBLISH_MODE || 'draft';
|
||||||
|
let pgCache = null;
|
||||||
|
function caycePool() {
|
||||||
|
if (!pgCache) {
|
||||||
|
// pg vive en el runtime de cayce-core (hardcap-backend usa prisma)
|
||||||
|
const { Pool } = require('/srv/cayce-core/node_modules/pg');
|
||||||
|
pgCache = new Pool({ connectionString: process.env.CAYCE_PGURL });
|
||||||
|
}
|
||||||
|
return pgCache;
|
||||||
|
}
|
||||||
|
function identityHash() {
|
||||||
|
try {
|
||||||
|
const { createHash } = require('crypto');
|
||||||
|
const { readFileSync } = require('fs');
|
||||||
|
const p = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md';
|
||||||
|
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /admin/articles/:id — update status (CONTENCIÓN: draft no publica)
|
||||||
|
router.patch('/articles/:id', async (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const { status } = req.body
|
||||||
|
if (status === 'PUBLISHED' && MODE !== 'live') {
|
||||||
|
try {
|
||||||
|
const art = await prisma.article.findUnique({ where: { id } })
|
||||||
|
const content = `${art?.title || id} | ${art?.summary || ''}`.slice(0, 400)
|
||||||
|
const ih = identityHash()
|
||||||
|
const r = await caycePool().query(
|
||||||
|
"INSERT INTO outbound_drafts (channel, type, content, identity_path, identity_hash, meta) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
|
||||||
|
['hardcap', 'article_publish', content,
|
||||||
|
process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md',
|
||||||
|
ih, JSON.stringify({ article_id: id })]
|
||||||
|
)
|
||||||
|
console.log(`[outbox] STAGED draft #${r.rows[0].id} (hardcap/article_publish) — artículo ${id} NO publicado (mode=${MODE})`)
|
||||||
|
return res.json({ staged: true, draft_id: String(r.rows[0].id), article_id: id })
|
||||||
|
} catch (e) {
|
||||||
|
// fail-closed: sin borrador, NO se publica. Sin volcar el comando ni el DSN al log.
|
||||||
|
console.error('[outbox] staging failed — PATCH descartado (fail-closed):', String((e && e.message) || e).slice(0, 120))
|
||||||
|
return res.status(500).json({ staged: false, error: 'staging failed; publish discarded (fail-closed)' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const article = await prisma.article.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(article)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Sources ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /admin/sources
|
||||||
|
router.get('/sources', async (_req, res) => {
|
||||||
|
const sources = await prisma.source.findMany({ orderBy: { name: 'asc' } })
|
||||||
|
res.json(sources)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /admin/sources
|
||||||
|
router.post('/sources', async (req, res) => {
|
||||||
|
const { name, rssUrl, tier, topicBias } = req.body
|
||||||
|
const source = await prisma.source.create({
|
||||||
|
data: { name, rssUrl, tier: tier ?? 2, topicBias: topicBias ?? [] },
|
||||||
|
})
|
||||||
|
res.json(source)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /admin/sources/:id — toggle active, update tier
|
||||||
|
router.patch('/sources/:id', async (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const { active, tier, topicBias } = req.body
|
||||||
|
|
||||||
|
const source = await prisma.source.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(active !== undefined ? { active } : {}),
|
||||||
|
...(tier !== undefined ? { tier } : {}),
|
||||||
|
...(topicBias !== undefined ? { topicBias } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(source)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Topic Filters ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /admin/topics
|
||||||
|
router.get('/topics', async (_req, res) => {
|
||||||
|
const topics = await prisma.topicFilter.findMany({ orderBy: { label: 'asc' } })
|
||||||
|
res.json(topics)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /admin/topics/:id — toggle active, adjust weight
|
||||||
|
router.patch('/topics/:id', async (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const { active, weight, keywordsInclude, keywordsExclude } = req.body
|
||||||
|
|
||||||
|
const topic = await prisma.topicFilter.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(active !== undefined ? { active } : {}),
|
||||||
|
...(weight !== undefined ? { weight } : {}),
|
||||||
|
...(keywordsInclude !== undefined ? { keywordsInclude } : {}),
|
||||||
|
...(keywordsExclude !== undefined ? { keywordsExclude } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(topic)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Manual Trigger ────────────────────────────────────────
|
||||||
|
|
||||||
|
// DELETE /admin/articles/all — reset all articles
|
||||||
|
router.delete('/articles/all', async (_req, res) => {
|
||||||
|
const result = await prisma.article.deleteMany({})
|
||||||
|
res.json({ deleted: result.count })
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /admin/ingest — trigger ingestion manually
|
||||||
|
router.post('/ingest', async (_req, res) => {
|
||||||
|
res.json({ message: 'Ingestion started' })
|
||||||
|
// Fire and forget
|
||||||
|
ingestAllSources().catch(console.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// test-admin-auth — verifica el middleware de /admin (hardcap-backend).
|
||||||
|
// Ejecutar en la VPS: node /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs
|
||||||
|
// (o node runtime/hardcap/test-admin-auth.mjs desde el repo)
|
||||||
|
// Requiere HARDCAP_ADMIN_TOKEN en el .env del servicio y el servicio activo en 127.0.0.1:3001.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
const BASE = 'http://127.0.0.1:3001'
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
readFileSync('/srv/hardcap-backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||||
|
)
|
||||||
|
const TOKEN = env.HARDCAP_ADMIN_TOKEN
|
||||||
|
if (!TOKEN) { console.error('HARDCAP_ADMIN_TOKEN no configurado — el test no puede pasar'); process.exit(2) }
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
async function check(name, path, opts, expected) {
|
||||||
|
const r = await fetch(BASE + path, { ...opts, signal: AbortSignal.timeout(8000) })
|
||||||
|
const ok = r.status === expected
|
||||||
|
if (!ok) failed++
|
||||||
|
console.log(`${ok ? '✓' : '✗'} ${name}: esperado ${expected}, obtenido ${r.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await check('sin token → 401', '/admin/articles?status=QUEUED', {}, 401)
|
||||||
|
await check('token incorrecto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'x'.repeat(64) } }, 401)
|
||||||
|
await check('token vacío en header → 401', '/admin/sources', { headers: { Authorization: 'Bearer ' } }, 401)
|
||||||
|
await check('token correcto → 200', '/admin/articles?status=QUEUED', { headers: { Authorization: `Bearer ${TOKEN}` } }, 200)
|
||||||
|
await check('header alternativo X-Admin-Token → 200', '/admin/sources', { headers: { 'x-admin-token': TOKEN } }, 200)
|
||||||
|
// la comparación debe ser en tiempo constante: un token de la misma longitud pero distinto NO pasa
|
||||||
|
await check('token misma longitud, distinto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'y'.repeat(TOKEN.length) } }, 401)
|
||||||
|
|
||||||
|
console.log(failed ? `\n${failed} TESTS FALLARON` : '\nTODOS LOS TESTS PASARON')
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
+34
-1
@@ -52,6 +52,13 @@ async function toolWebFetch({ url }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toolAddOpportunity(a) {
|
async function toolAddOpportunity(a) {
|
||||||
|
// directiva supervisor 2026-10-31: sin dedup el agente re-registraba la
|
||||||
|
// misma oportunidad (OpenSats ×10, HRF ×6). Ahora: si existe, se rechaza.
|
||||||
|
const dup = await pgc.query('SELECT id FROM opportunities WHERE lower(source_name) = lower($1) LIMIT 1', [a.source_name])
|
||||||
|
if (dup.rowCount) {
|
||||||
|
await log('yellow', 'opportunity_duplicate_rejected', { source_name: a.source_name, existing_id: dup.rows[0].id })
|
||||||
|
return { duplicate: true, existing_id: dup.rows[0].id, note: 'ya registrada — no se duplica; continuá con el resto del ciclo' }
|
||||||
|
}
|
||||||
await pgc.query(
|
await pgc.query(
|
||||||
`INSERT INTO opportunities (source_name, url, mechanic, fit, effort, expected, first_action)
|
`INSERT INTO opportunities (source_name, url, mechanic, fit, effort, expected, first_action)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
@@ -165,10 +172,31 @@ Una acción por respuesta. El sistema ejecuta y te devuelve el resultado. Si tu
|
|||||||
// ── el loop
|
// ── el loop
|
||||||
async function runCycle() {
|
async function runCycle() {
|
||||||
const state = await stateSummary()
|
const state = await stateSummary()
|
||||||
|
// directiva del supervisor (2026-10-31): grants suspendidos, BTCPay prioridad 1.
|
||||||
|
// No modifica el MANDATE (eso es 🔴); es una orden operativa reversible con
|
||||||
|
// CAYCE_DIRECTIVE_BTCPAY=0 en el .env.
|
||||||
|
const DIRECTIVE = process.env.CAYCE_DIRECTIVE_BTCPAY === '1' ? `
|
||||||
|
|
||||||
|
=== DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) ===
|
||||||
|
1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2.
|
||||||
|
2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador.
|
||||||
|
3. Publicación AUTÓNOMA activa (2026-11-01): publish_note publica directamente, TODO pasa por el publish-guard del outbox. Si el guard rechaza, registra el motivo y sigue. Kill switch inmediato: /srv/cayce-core/PUBLISH_OFF.
|
||||||
|
` : ''
|
||||||
|
// directiva del supervisor (2026-11-01): cold storage sin marcas. Reversible:
|
||||||
|
// CAYCE_DIRECTIVE_COLDSTORAGE=0 en el .env.
|
||||||
|
const DIRECTIVE_CS = process.env.CAYCE_DIRECTIVE_COLDSTORAGE === '0' ? '' : `
|
||||||
|
|
||||||
|
=== DIRECTIVA DEL SUPERVISOR (2026-11-01 — vigente hasta nuevo aviso) ===
|
||||||
|
4. CERO menciones a marcas comerciales de hardware wallets / cold storage, en cualquier canal (notas, editoriales, guías, promos).
|
||||||
|
5. Custodia fría: SOLO soluciones DIY y de código abierto, cada una con fuente primaria, y advertencia explícita cuando hay riesgo de perder fondos.
|
||||||
|
6. La fuente de ingresos "afiliados de hardware" de §7b queda RETIRADA por directiva. El MANDATE no se toca.
|
||||||
|
`
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: `${MANDATE}
|
content: `${MANDATE}
|
||||||
|
${DIRECTIVE}
|
||||||
|
${DIRECTIVE_CS}
|
||||||
|
|
||||||
=== TU ESTADO ACTUAL ===
|
=== TU ESTADO ACTUAL ===
|
||||||
${JSON.stringify(state, null, 2)}
|
${JSON.stringify(state, null, 2)}
|
||||||
@@ -261,7 +289,12 @@ Empieza ahora. Cuando hayas cumplido §7b, responde finish con un summary honest
|
|||||||
const finalSummary = summary ?? 'max steps'
|
const finalSummary = summary ?? 'max steps'
|
||||||
await log(s7b_ok ? 'green' : 'red', 'initiative_cycle',
|
await log(s7b_ok ? 'green' : 'red', 'initiative_cycle',
|
||||||
{ steps, productive, repairs, trace, summary: finalSummary }, s7b_ok ? 'done' : 'error')
|
{ steps, productive, repairs, trace, summary: finalSummary }, s7b_ok ? 'done' : 'error')
|
||||||
await dm(`INITIATIVE ${new Date().toISOString().slice(0, 10)} — ciclo ${s7b_ok ? 'cumplido' : 'falló'} · ${steps} pasos · ${productive} productivas · ${trace.map(t => t.action).join(' → ') || 'sin acciones'}\n\n${finalSummary}`)
|
const drafts24 = (await pgc.query(
|
||||||
|
`SELECT id, type, content FROM outbound_drafts WHERE ts > now() - interval '24 hours' ORDER BY id`
|
||||||
|
).catch(() => ({ rows: [] }))).rows
|
||||||
|
const draftLines = drafts24.map(d => `#${d.id} [${d.type}] ${String(d.content).replace(/\s+/g, ' ').split(' ').slice(0, 15).join(' ')}`)
|
||||||
|
const draftBlock = `\n\nBORRADORES contención (24h: ${draftLines.length}) — revisión y aprobación pendientes del operador:\n${draftLines.join('\n') || 'ninguno'}`
|
||||||
|
await dm(`INITIATIVE ${new Date().toISOString().slice(0, 10)} — ciclo ${s7b_ok ? 'cumplido' : 'falló'} · ${steps} pasos · ${productive} productivas · ${trace.map(t => t.action).join(' → ') || 'sin acciones'}\n\n${finalSummary}${draftBlock}`)
|
||||||
return { steps, productive, s7b_ok, summary: finalSummary, trace }
|
return { steps, productive, s7b_ok, summary: finalSummary, trace }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import { ImapFlow } from 'imapflow'
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import { gateOutbound } from './outbox.mjs'
|
import { gateOutbound } from '/srv/cayce-core/outbox.mjs'
|
||||||
|
|
||||||
const HOME = '/home/ubuntu/cayce-mail'
|
const HOME = '/home/ubuntu/cayce-mail'
|
||||||
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
|
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
|
||||||
|
|||||||
+31
-6
@@ -11,12 +11,18 @@
|
|||||||
//
|
//
|
||||||
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
|
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { guardCheck, logRejection, recordPublish, ensureGuardTables, FORCE_DRAFT_CHANNELS } from './guard.mjs'
|
||||||
import { createHash } from 'node:crypto'
|
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'
|
const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md'
|
||||||
|
|
||||||
export function publishMode() {
|
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()
|
return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +73,7 @@ let ownPool = null
|
|||||||
async function poolFor(poolPg) {
|
async function poolFor(poolPg) {
|
||||||
if (poolPg) return poolPg
|
if (poolPg) return poolPg
|
||||||
if (!ownPool) {
|
if (!ownPool) {
|
||||||
const pg = (await import('pg')).default
|
const pg = require_('/srv/cayce-core/node_modules/pg')
|
||||||
const env = Object.fromEntries(
|
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)])
|
readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||||
)
|
)
|
||||||
@@ -90,15 +96,34 @@ export async function stageDraft(poolPg, { channel, type, content, original_even
|
|||||||
}
|
}
|
||||||
|
|
||||||
// El único punto por el que debe pasar cualquier salida a terceros.
|
// El único punto por el que debe pasar cualquier salida a terceros.
|
||||||
// publishFn SOLO se invoca en modo live.
|
// 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 }) {
|
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') {
|
if (publishMode() === 'live') {
|
||||||
return { ...(await publishFn()), staged: false }
|
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 {
|
try {
|
||||||
return await stageDraft(poolPg, { channel, type, content, original_event, meta })
|
return await stageDraft(p, { channel, type, content, original_event, meta })
|
||||||
} catch (e) {
|
} 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`)
|
console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`)
|
||||||
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
|
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
// Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly.
|
// Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly.
|
||||||
// Ledger en DB (posts_log) para medir y optimizar.
|
// Ledger en DB (posts_log) para medir y optimizar.
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
import { gateOutbound } from './outbox.mjs'
|
import { gateOutbound } from '/srv/cayce-core/outbox.mjs'
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
|
||||||
const HOME = '/home/ubuntu/cayce-social'
|
const HOME = '/home/ubuntu/cayce-social'
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/node
|
||||||
|
// publish-10050 — publica el kind 10050 (NIP-65 memento: relays de inbox
|
||||||
|
// para DMs/gift wraps) de Cayce. Autorizado explícitamente por el operador.
|
||||||
|
// Se ejecuta UNA vez (script, no daemon).
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { SimplePool, finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||||
|
|
||||||
|
const env = Object.fromEntries(
|
||||||
|
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||||
|
)
|
||||||
|
const skRaw = env.NSEC_HEX
|
||||||
|
const { nsecDeserialize } = await import('nostr-tools') // soporta hex y bech32 vía fallback
|
||||||
|
let sk
|
||||||
|
try { sk = skRaw.startsWith('nsec1') ? nsecDeserialize(skRaw) : Buffer.from(skRaw, 'hex') } catch (e) {
|
||||||
|
console.error('nsec ilegible:', String(e).slice(0, 80)); process.exit(1)
|
||||||
|
}
|
||||||
|
const pk = getPublicKey(sk)
|
||||||
|
|
||||||
|
// relays donde Cayce ESCUCHA DMs (los mismos del listener) — son su inbox
|
||||||
|
const INBOX = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://relay.snort.social',
|
||||||
|
'wss://nostr-pub.wellorder.net',
|
||||||
|
'wss://public.relaying.io',
|
||||||
|
]
|
||||||
|
const ev = finalizeEvent({
|
||||||
|
kind: 10050,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: INBOX.map(r => ['relay', r]),
|
||||||
|
content: '',
|
||||||
|
}, sk)
|
||||||
|
console.log('[10050] pubkey:', pk)
|
||||||
|
console.log('[10050] relays de inbox declarados:', INBOX.join(', '))
|
||||||
|
|
||||||
|
const pool = new SimplePool()
|
||||||
|
const ok = []
|
||||||
|
for (const r of INBOX) {
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
pool.publish([r], ev),
|
||||||
|
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000)),
|
||||||
|
])
|
||||||
|
ok.push(r)
|
||||||
|
} catch (e) { console.error(`[10050] ${r}: ${String(e).slice(0, 60)}`) }
|
||||||
|
}
|
||||||
|
pool.close(INBOX)
|
||||||
|
console.log(`[10050] publicado/aceptado en ${ok.length}/${INBOX.length} relays — event id ${ev.id}`)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// 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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { loadIdentityBlock, identityFingerprint } from './outbox.mjs'
|
import { loadIdentityBlock, identityFingerprint } from '/srv/cayce-core/outbox.mjs'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
|
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-credential-leaks — falla si journald reciente contiene patrones de credencial.
|
||||||
|
# Regla permanente: los secretos jamás en logs. Ejecutar manualmente o desde cron
|
||||||
|
# (sin timer propio: no fue autorizado).
|
||||||
|
set -euo pipefail
|
||||||
|
SINCE="${1:-2 hours ago}"
|
||||||
|
HITS=$(journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -cE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" || true)
|
||||||
|
if [ "$HITS" -gt 0 ]; then
|
||||||
|
echo "ALERTA: $HITS líneas de journald (desde $SINCE) contienen patrones de credencial" >&2
|
||||||
|
journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -nE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" | sed -E "s#(://[A-Za-z0-9_]+:)[^@[:space:]]{8,}@#\1***@#" | head -5
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: sin patrones de credencial en journald desde $SINCE"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Regla permanente (2026-10-31): ningún fichero versionado contiene patrones
|
||||||
|
de credenciales. Placeholders de .env.example están permitidos.
|
||||||
|
|
||||||
|
Fallará si alguien mete un DSN con contraseña real, un token en claro, o
|
||||||
|
cualquier valor que deba vivir solo en .env / tokens.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SCAN_DIRS = ["runtime", "deploy", "docs", "scripts", "prompts"]
|
||||||
|
SCAN_GLOBS = ["*.py", "*.mjs", "*.js", "*.ts", "*.md", "*.yml", "*.yaml", "*.sh", "*.sql"]
|
||||||
|
|
||||||
|
# DSN con credencial literal (user:pass@host) — excluye placeholders conocidos
|
||||||
|
DSN = re.compile(r"(postgres|postgresql|mysql|mongodb)(\+[a-z]+)?://[A-Za-z0-9_]+:(?!password\b|change-me\b|<[^>]+>)[^@\s/]{8,}@", re.I)
|
||||||
|
# tokens hex largos fuera de tests (los fixtures de tests están allowlistados por diseño)
|
||||||
|
HEXTOKEN = re.compile(r"\b[0-9a-f]{64}\b")
|
||||||
|
# nsec bech32
|
||||||
|
NSEC = re.compile(r"nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}")
|
||||||
|
ALLOW_SUBSTR = ("user:password", "change-me", "<", "EXAMPLE", "example", "itest-pg", "0011", "11*", "22*", "33*", "44*")
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_files():
|
||||||
|
for d in SCAN_DIRS:
|
||||||
|
base = REPO / d
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
for pat in SCAN_GLOBS:
|
||||||
|
for f in base.rglob(pat):
|
||||||
|
if ".git" in f.parts or "node_modules" in f.parts:
|
||||||
|
continue
|
||||||
|
yield f
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dsn_with_credentials():
|
||||||
|
hits = []
|
||||||
|
for f in _iter_files():
|
||||||
|
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
||||||
|
if DSN.search(line) and not any(a in line for a in ALLOW_SUBSTR):
|
||||||
|
hits.append(f"{f}:{i}")
|
||||||
|
assert not hits, f"DSN con credencial literal en: {hits}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_nsec_in_versioned_files():
|
||||||
|
hits = []
|
||||||
|
for f in _iter_files():
|
||||||
|
if "/tests/" in str(f):
|
||||||
|
continue
|
||||||
|
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
||||||
|
if NSEC.search(line):
|
||||||
|
hits.append(f"{f}:{i}")
|
||||||
|
assert not hits, f"nsec en fichero versionado: {hits}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_raw_hex_tokens_outside_tests():
|
||||||
|
"""Tokens hex de 64 chars fuera de tests/ y del conftest (las claves de
|
||||||
|
test viven solo en tests/). El outbox no debe contener secretos."""
|
||||||
|
hits = []
|
||||||
|
for f in _iter_files():
|
||||||
|
if "/tests/" in str(f) or f.name == "cayce-identity.md":
|
||||||
|
continue
|
||||||
|
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
|
||||||
|
if HEXTOKEN.search(line):
|
||||||
|
hits.append(f"{f}:{i}")
|
||||||
|
assert not hits, f"posible token hex versionado: {hits}"
|
||||||
Reference in New Issue
Block a user