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