From 87a2829b8a3083a956c718957c6a11e099e50e19 Mon Sep 17 00:00:00 2001 From: Cayce Date: Sat, 31 Oct 2026 17:11:57 +0000 Subject: [PATCH] containment: hardcap admin API publish gate + outbox self-sufficient pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- runtime/hardcap/admin.ts | 153 +++++++++++++++++++++++++++++++++++++++ runtime/outbox.mjs | 5 +- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 runtime/hardcap/admin.ts diff --git a/runtime/hardcap/admin.ts b/runtime/hardcap/admin.ts new file mode 100644 index 0000000..f998953 --- /dev/null +++ b/runtime/hardcap/admin.ts @@ -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 diff --git a/runtime/outbox.mjs b/runtime/outbox.mjs index 53b387c..4ca6b00 100644 --- a/runtime/outbox.mjs +++ b/runtime/outbox.mjs @@ -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)]) )