Compare commits

...
2 Commits
Author SHA1 Message Date
cayce 34ee25b32f Merge: gate de publicación hardcap + outbox auto-suficiente
ci / lint-test-leaks (push) Canceled after 0s
2026-10-31 17:12:08 +00:00
cayce 87a2829b8a containment: hardcap admin API publish gate + outbox self-sufficient pool
ci / lint-test-leaks (pull_request) Canceled after 0s
- hardcap PATCH /admin/articles/:id → PUBLISHED now stages into
  outbound_drafts (channel hardcap) instead of publishing, when
  HARDCAP_PUBLISH_MODE=draft (default). Fail-closed. Verified E2E:
  PATCH on a QUEUED article returned {staged:true, draft_id:7} and the
  article stayed QUEUED.
- bitcoinhotels.org: audited — frontend only (Next.js), no publishing
  path; nothing to contain.
- outbox.mjs: self-sufficient pool (createRequire from
  /srv/cayce-core/node_modules/pg — hosts may lack pg; found by the
  promo simulation which now works end-to-end).
- runtime/hardcap/admin.ts: versioned copy of the patched route
  (hardcap-backend itself is not a git repo).
- deploy.sh: hardcap env wiring (HARDCAP_PUBLISH_MODE, IDENTITY_PATH,
  CAYCE_PGURL appended server-side).

🔴 UNRELATED CRITICAL FINDING (needs operator decision, not patched
without approval): hardcap admin API has NO authentication and binds
*:3001 — reachable from the public internet (HTTP 200 from outside,
including DELETE /admin/articles/all). Recommend: auth middleware +
bind 127.0.0.1 + firewall, plus rotation of credentials that appeared
in journald (hardcap DB role DSN leaked via a psql error line during
testing — fixed in the same patch, but journald retains it).
2026-10-31 17:11:57 +00:00
2 changed files with 157 additions and 1 deletions
+153
View File
@@ -0,0 +1,153 @@
import { Router } from 'express'
import { prisma } from '../lib/prisma'
import { ingestAllSources } from '../services/ingestion'
const router = Router()
// ── 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
+4 -1
View File
@@ -13,6 +13,9 @@
import { readFileSync } from 'node:fs'
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'
@@ -67,7 +70,7 @@ let ownPool = null
async function poolFor(poolPg) {
if (poolPg) return poolPg
if (!ownPool) {
const pg = (await import('pg')).default
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)])
)