Autoalojamiento (despliegue soberano)
Futuros está diseñado para ser redesplegado en la región. Este es el Pilar 2 (el Modelo Soberano): un gobierno latinoamericano o un multilateral puede ejecutar la plataforma completa, el atlas, el corpus precalculado, la API pública y el asistente de IA, sobre infraestructura dentro de su propia jurisdicción, apuntando la inferencia a un gateway de pesos abiertos en la región para que el corpus y cada consulta de usuario permanezcan en suelo soberano.
Como la plataforma es una SPA estática + un corpus JSON precalculado + un conjunto pequeño de funciones serverless, el autoalojamiento viene en dos niveles.
Dos niveles, un repo
El Dockerfile + Caddyfile del repo producen un despliegue estático: la SPA, el corpus precalculado de public/data/, la API Pública v1 y los embeds. Ese contenedor (Caddy sirviendo dist/) no ejecuta las funciones serverless de api/: así que el chat, el servidor MCP, los endpoints de contribución/revocación del Fideicomiso de Datos, el consenso y las alertas no son servidos por él. Para ejecutar esos servicios interactivos necesitas un host que ejecute las funciones Node de api/* (Vercel, o cualquier runtime de Node/funciones), más el env de abajo.
Nivel 1 — espejo soberano estático (Docker + Caddy)
El Dockerfile multi-etapa incluido construye con Bun y entrega una imagen Caddy diminuta que sirve el build estático:
FROM oven/bun:1.3 AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 8080
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]Construir y ejecutar:
docker build -t futuros .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080Desde la versión con cuentas (octubre de 2026)
La SPA exige iniciar sesión: el contenedor estático de arriba sirve la interfaz, pero el muro de acceso (middleware.ts) y /api/auth/* necesitan las funciones de servidor, Postgres (Neon o equivalente) y el correo transaccional. Ver Cuentas y acceso. La guía completa de despliegue con base de datos y migraciones llega con la versión de organizaciones y acceso de máquina; hasta entonces, un self-host estático solo es útil con AUTH_WALL_DISABLED=1 en un entorno privado.
El Caddyfile escucha en :{$PORT:8080}, comprime con gzip/zstd, y se comporta, matcher por matcher:
| Matcher | Comportamiento |
|---|---|
/assets/* | Cache-Control: public, max-age=31536000, immutable — bundles con hash |
/data/* | max-age=600, s-maxage=3600 — el corpus precalculado se renueva a minutos de un redespliegue |
/research/* | igual que /data/* — JSON de la biblioteca de investigación, regenerado en cada build |
/atlas/… con extensión, /geo/* | max-age=86400, immutable — bundles geográficos estáticos. La regla de /atlas/ es un path_regexp que exige extensión de archivo, a propósito: /atlas/<ISO3> es una ruta de la SPA, no un archivo, y marcarla immutable fijaba el shell de la app un día entero en cada caché; tras el siguiente despliegue pedía chunks de /assets/ que ya no existen, y ni recargar lo arreglaba. Un glob no bastaría: en Caddy el * final cruza / y uno intermedio no, así que /atlas/*.json se saltaría /atlas/adm1-shapes/MEX.json. Misma regla que vercel.json. |
/embed/* | elimina X-Frame-Options y repite la política CSP completa con frame-ancestors * como una directiva más: script-src, object-src 'none' y base-uri 'self' siguen vigentes en los embeds. Un header de Caddy reemplaza el campo entero, así que escribir solo frame-ancestors * habría borrado el resto de la política en todo /embed/* (que no es un subárbol estático, son rutas de la SPA) |
| todo lo demás | El conjunto completo de headers de producción, idéntico al de vercel.json: Content-Security-Policy en modo de aplicación (default-src 'self', allowlist de orígenes para script/style/font/connect, object-src 'none', frame-ancestors 'self', base-uri 'self', form-action 'self'), Strict-Transport-Security: max-age=63072000; includeSubDomains, Cross-Origin-Opener-Policy: same-origin-allow-popups, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy denegando cámara/micrófono/geolocalización, y el header Server eliminado |
El autoalojamiento ya no es más débil que el despliegue en Vercel
El Caddyfile envía la misma postura de seguridad que la configuración de borde de Vercel, header por header. Antes no enviaba ninguno de los headers de producción (ni CSP, ni HSTS, ni COOP), así que un espejo soberano heredaba un sitio más débil que el que publicamos. Las decisiones de la política están documentadas en el propio Caddyfile: 'unsafe-inline' en script-src es el canje honesto de un despliegue estático que no puede acuñar un nonce por petición; 'wasm-unsafe-eval' y cdn.jsdelivr.net son DuckDB-WASM; los orígenes de fuentes en connect-src son el service worker volviendo a descargarlas; y COOP es same-origin-allow-popups (no same-origin) porque la exportación a PDF necesita la relación con el opener. Si editas uno de los dos archivos, edita el otro: que diverjan es exactamente el fallo que esto corrige.
El orden de resolución es try_files {path} {path}/ /spa.html: un archivo real siempre gana sobre el fallback de la SPA. Por eso los artefactos JSON precalculados de /api/v1/* se sirven incluso en el Nivel 1 (son archivos planos en dist/), mientras que una ruta dinámica como /api/chat no tiene handler ahí y cae al shell de la SPA. Esto refleja el rewrite de vercel.json de toda ruta sin extensión y no reservada hacia /spa.html, con una diferencia deliberada: el patrón de Vercel también excluye /api/*, porque en Vercel las funciones viven ahí.
El objetivo del fallback importa: postbuild renombra dist/index.html→spa.html y dist/landing.html→index.html (de modo que /index.html es la landing, no el shell de la app), luego prerrenderiza la landing (scripts/prerender-landing.mjs) y elimina cualquier CLAUDE.md que se haya filtrado a dist/. railway.json conecta el mismo Dockerfile para Railway: builder DOCKERFILE, health check /intro (timeout de 30 s), política de reinicio ON_FAILURE con 3 reintentos.
Este nivel es un espejo de soberanía de datos completo y capaz de operar offline: la UI completa del atlas y todo el corpus citado, alojados donde sea que ejecutes el contenedor. Todo lo que necesita un servidor (abajo) se degrada con honestidad, la app sigue usable, las funciones interactivas anuncian que no están configuradas en lugar de fingir un resultado.
Nivel 2 — plataforma completa (funciones serverless + inferencia soberana)
Las superficies interactivas viven en api/* como funciones serverless de Node (chat, mcp, contribute, revoke, consensus, intake, alerts-subscribe, alerts-digest, o, auth/*: intake recibe propuestas de pilotos, pedidos de introducción y reportes de inconsistencias de los visitantes, persistiendo en una cola de Upstash con notificación por Mailgun, honesta-cuando-no-configurada como el resto). Despliégalas en un runtime que las ejecute (Vercel es el objetivo de referencia) y fija las variables de entorno de abajo. Los assets estáticos y el corpus son el mismo build que el Nivel 1.
Variables de entorno
Agrupadas por funcionalidad. Todo es opcional: cada clave sin fijar degrada con honestidad una funcionalidad específica (ver la tabla más abajo). Las claves de cliente llevan prefijo VITE_ y quedan incrustadas en el build; el resto son solo del lado del servidor.
Nota: el
env.exampledel repo documenta solo las claves de mapa, analítica, chat/recuperación y la sal del ledger de confianza. Las claves de Upstash, alertas (Mailgun / webhooks) y Perplexity de abajo son leídas por el código pero no están enenv.example: la lista autoritativa es esta.
Build / cliente (incrustadas, con prefijo VITE_):
| Var | Funcionalidad | Sin ella |
|---|---|---|
VITE_MAPBOX_TOKEN | Configuración muerta: no la fijes. Ningún código la lee. El mapa del atlas es SVG offline puro (src/components/MapPane.tsx: sin Mapbox, sin MapLibre, sin token, sin tiles), y el único rastro que queda es la declaración de tipo en src/vite-env.d.ts | Ningún efecto: el atlas se renderiza igual. env.example la lista solo para que un operador que la encuentre en un proyecto heredado sepa que puede borrarla; una credencial pegada en una variable que nadie lee es puro riesgo sin contrapartida |
VITE_POSTHOG_HOST | Override de región de PostHog | Por defecto US Cloud |
Analítica en un autoalojamiento: la clave de proyecto de PostHog está hardcodeada en
src/lib/analytics.ts(una variable de entornoVITE_POSTHOG_KEYse ignora deliberadamente), y la inicialización está restringida a los hostnamesfuturos.xyz/*.futuros.xyz: un espejo soberano en otro dominio no emite ninguna analítica sin importar el env. El sitio de docs (docs/.vitepress/posthog-snippet.ts) usa la misma clave hardcodeada y el mismo gate de hostname. Apuntar la analítica a tu propio proyecto de PostHog significa editar esos archivos, no fijar una variable.
Las compuertas que fallan cerrado
Antes de cualquier clave de proveedor, tres interruptores explícitos gobiernan si un endpoint dinámico existe siquiera. Todos fallan cerrado: con la variable sin fijar, la superficie devuelve un rechazo limpio en vez de quedar abierta. Un .env vacío es, por diseño, una postura correcta, nunca una fuga.
| Var | Qué abre | Sin ella |
|---|---|---|
CHAT_ENABLED | Abre POST /api/chat (además necesita una clave de proveedor y el rate limit de Upstash). El chat en el sitio (SPA) sigue gratuito; llamadas programáticas / otros orígenes necesitan una clave prepago ft_live_… de /acceso cuando el lever de acceso está encendido | 403 — el asistente no responde. Este es el paso que más se olvida al autoalojar: con ANTHROPIC_API_KEY configurada pero sin este interruptor, el chat sigue devolviendo 403 |
MCP_ENABLED | Abre POST /api/mcp (además necesita el rate limit de Upstash). Quien llama debe presentar una clave prepago (Authorization: Bearer ft_live_…) cuando el lever de acceso está encendido | 403 {"error":"mcp_disabled"}. GET /api/mcp?health=1 sigue respondiendo y lo reporta en enabled |
SCRAPE_WRITE_ENABLED | La vía de escritura del scraping | Modo solo-observación: las escrituras se rechazan |
Cada interruptor debe valer literalmente la cadena "true".
Estos tres viven junto a los demás en server/http/env-levers.ts, la lista canónica de 20 palancas de producción (con su id, las variables que la componen, qué abre y una sonda pública para verificarla). Esa lista se sirve por GET /api/env-levers y se publica en /confianza, de modo que un operador (o un periodista) puede auditar la postura de un despliegue sin acceso a su entorno. leverStatus() reporta únicamente un booleano set por palanca: nunca el valor del secreto.
Asistente de IA, proveedor de inferencia (lado servidor; gana la primera coincidencia):
| Var(s) | Funcionalidad |
|---|---|
SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEY | Ruta soberana del Pilar 2. Apunta el mismo bucle de agente Anthropic-Messages a un gateway de pesos abiertos en la región / autoalojado (LiteLLM, o vLLM/TGI detrás de un shim compatible con Messages, sirviendo Llama/Qwen/DeepSeek/Mistral). Cuando ambas están fijadas ganan sobre OpenRouter/Anthropic y el chat muestra una insignia de "modo soberano" — la consulta + el corpus nunca salen del host. |
SOVEREIGN_INFERENCE_MODEL | El served-model-name de tu gateway (por defecto local-model) |
SOVEREIGN_INFERENCE_LABEL | Etiqueta de UI para el proveedor soberano (por defecto Soberano) |
SOVEREIGN_INFERENCE_REGION | Declara la región del gateway; la única vía por la que la insignia afirma la residencia "en la región", más fuerte |
OPENROUTER_API_KEY | Fallback gestionado: enruta vía el endpoint nativo-Anthropic de OpenRouter (modelo por defecto anthropic/claude-sonnet-4.6) |
ANTHROPIC_API_KEY | Llama a api.anthropic.com directamente (por defecto claude-sonnet-4-6) |
CHAT_MODEL / CHAT_FOLLOWUP_MODEL | Sobrescriben los ids de modelo de respuesta / seguimiento para el proveedor activo |
Los proveedores forman una cadena ordenada de failover: soberano → OpenRouter → Anthropic. El primer proveedor configurado es el primario; cada clave adicional se convierte en un fallback transparente al que el handler cambia a mitad de petición si el primario falla (los ids de modelo se re-resuelven por proveedor; CHAT_MODEL / CHAT_FOLLOWUP_MODEL se ligan solo al primario). Una sola clave funciona bien; una segunda compra redundancia. Sin ninguna fijada, el chat emite un evento not_configured y declina en lugar de fallar. SOVEREIGN_INFERENCE_REGION (opcional) declara la región geográfica del gateway, es la única vía por la que el chat afirma la aseveración más fuerte de "en la región" detrás de la insignia soberana; la residencia nunca se infiere de la mera presencia de un endpoint.
Asistente de IA, recuperación y web externa (lado servidor):
| Var(s) | Funcionalidad | Sin ella |
|---|---|---|
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL) | search_corpus semántico sobre el índice precalculado + rerank cross-encoder | Se degrada a búsqueda por palabras clave/BM25 (sin fallo duro) |
EXA_API_KEY | Fallback web de último recurso del chat (web_search/fetch_url) + ingesta de pulso | El chat responde solo desde el corpus y declina preguntas fuera del corpus |
PERPLEXITY_API_KEY (+ PERPLEXITY_MODEL) | Síntesis web en vivo perplexity_search (chat + MCP) | La herramienta devuelve una nota de "no configurado"; las respuestas quedan solo-corpus |
Fideicomiso de Datos (Pilar 1) y consenso: Upstash Redis (lado servidor):
| Var(s) | Funcionalidad | Sin ella |
|---|---|---|
KV_REST_API_URL + KV_REST_API_TOKEN o UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN | Ledger de confianza append-only (recibos de /contribuir, /api/revoke, /api/intake), almacén de votos de consenso (/api/consensus), y rate limiting de la API entre instancias. Misma precedencia en los tres (KV_* primero; ver server/http/redis-env.ts) | Devuelve un recibo válido con persisted:false; el consenso se degrada a solo-local; los límites de tasa recaen en memoria por instancia — nunca finge persistencia |
TRUST_ID_SALT | Sal secreta para el id seudónimo de contribuyente en los recibos del ledger (contributor_id = sha256(sal:email); el ledger nunca almacena el correo crudo; la tabla user de cuentas sí lo guarda, porque el acceso sin contraseña lo necesita) | En desarrollo local recae en la sal por defecto pública, presente en el repo (cualquiera puede recomputar el hash y confirmar si un correo dado corresponde a un registro del ledger). En un despliegue real (VERCEL presente), POST /api/contribute se niega a emitir recibos (500 explícito) hasta que se configure — nunca degrada en silencio a la sal pública. Fija un secreto de alta entropía en producción |
RATE_LIMIT_KEY_SALT | Sal del HMAC-SHA256 con el que se construye la clave del rate limit por dirección de /api/alerts-subscribe (alerts-subscribe-addr:<digest de 32 hex>), para que ningún correo de suscriptor aparezca en el keyspace de Redis (un keyspace compartido por todos los endpoints y visible en cualquier listado REST o backup) | Recae en TRUST_ID_SALT, y si tampoco está, en una sal por defecto pública, presente en el repo. La partición por destinatario es idéntica en los tres casos; lo único que se pierde es que el digest sea impredecible para quien ya conoce una dirección. La dirección que se almacena, a la que se envía y que se devuelve al cliente sigue siendo exactamente la que el usuario escribió |
RATE_LIMIT_REQUIRE_STORE | Fijada a 1, endurece las superficies de pago (chat, MCP, las que piden failClosed): "no hay almacén de rate limit configurado" pasa a ser falla dura sobre un despliegue gestionado, y un almacén caído deja de caer al techo degradado. En ambos casos la respuesta es "temporalmente no disponible" en vez de servir | Desactivada por defecto. Sin almacén configurado los límites son por instancia (es decir, multiplicados por el número de instancias vivas) y solo queda una advertencia en el log. Un almacén configurado que empieza a fallar nunca vuelve al techo completo: sin esta variable cae a un techo por instancia mucho más estrecho (chat: 6/min por IP, 30/min y 120/h globales; MCP: 10/min por IP); con ella, rechaza. Es opt-in para que encender el modo estricto sea un acto deliberado, una vez que sabes que Redis está aprovisionado, y no un efecto colateral de desplegar. No aplica fuera de un despliegue gestionado: un bun test, un vite local o un autoalojamiento propio siguen abiertos |
TRUST_ID_SALT_PREVIOUS | Rotación de la sal: mueve aquí el valor anterior (separados por comas si hay varios); la revocación empareja cada promesa antigua con su sal original vía la etiqueta pública salt_version, así que rotar nunca rompe la revocabilidad | Rotar TRUST_ID_SALT sin retener la sal anterior deja irrevocables las promesas escritas bajo ella |
Los alias de Vercel KV KV_REST_API_URL / KV_REST_API_TOKEN y los nombres nativos UPSTASH_REDIS_REST_URL / _TOKEN son aceptados por el ledger del Fideicomiso, el almacén de consenso y el rate limiter (misma precedencia: KV_* primero). Basta con un par; un despliegue solo-KV (el caso típico de Marketplace) deja de dejar los recibos en persisted:false.
Cuentas: Better Auth + Neon Postgres + AgentMail (lado servidor):
Las cuentas de usuario (código por correo, enlace mágico; Google opcional y apagado por defecto), las sesiones, el historial de conversaciones y los elementos guardados viven en Postgres, no en Redis. El código asume Neon (@neondatabase/serverless, driver HTTP, sin pool que calentar) pero cualquier Postgres 15+ alcanzable por HTTPS con el protocolo de Neon, o un Postgres clásico detrás de un proxy compatible, sirve igual: la cadena de conexión es la única configuración.
| Var(s) | Funcionalidad | Sin ella |
|---|---|---|
DATABASE_URL | Cadena de conexión con pool (PgBouncer) que usan las funciones en tiempo de ejecución | Fail-closed: todo /api/auth/* responde 503 {"error":"auth_not_configured"}; el resto del sitio no cambia |
DATABASE_URL_UNPOOLED | Cadena de conexión directa para las migraciones (Neon documenta que el DDL por el host con pool falla de forma intermitente) | Las migraciones usan DATABASE_URL; con Neon, prefiere fijar ambas |
BETTER_AUTH_SECRET | Firma de sesiones y tokens; 32 caracteres como mínimo (openssl rand -base64 32). Un secreto más corto cuenta como no fijado | Fail-closed: mismo 503 que arriba |
BETTER_AUTH_URL | Origen público exacto (https://futuros.xyz): Google rechaza una redirect_uri que difiera en host o esquema | Se deriva de VERCEL_PROJECT_PRODUCTION_URL en producción y de VERCEL_URL en previews; en local, http://localhost:5173 |
AGENTMAIL_API_KEY (+ AUTH_EMAIL_INBOX, por defecto acceso@futuros.xyz) | Envío del correo de acceso (código de seis dígitos + enlace mágico en un solo mensaje) vía AgentMail; el inbox es la dirección remitente y debe estar en un dominio verificado. Tiene prioridad sobre Resend | El endpoint responde igual (nunca revela si el correo existe) pero no sale ningún mensaje; queda una línea [auth] email_failed en el log y una fila email_failed en audit_event |
RESEND_API_KEY (+ AUTH_EMAIL_FROM, por defecto Futuros <acceso@futuros.xyz>) | Alternativa a AgentMail: mismo correo vía Resend; se usa solo si AGENTMAIL_API_KEY no está fijada | Igual que arriba cuando tampoco hay AGENTMAIL_API_KEY |
VITE_AUTH_GOOGLE (variable de build) + GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET | "Continuar con Google" (prompt=select_account, vinculación de cuentas solo con Google). Opcional y apagado por defecto: sin VITE_AUTH_GOOGLE=1 no se muestra el botón ni el vínculo en /cuenta. Registra https://<tu-dominio>/api/auth/callback/google como redirect URI | Google no se ofrece; el acceso por código sigue funcionando. Las previews de Vercel nunca lo ofrecen (Google no admite comodines *.vercel.app) |
AUTH_WALL_DISABLED | Interruptor de emergencia del muro de acceso del middleware (llega en la siguiente versión). Fijada a 1 deja el sitio como hoy | Con el muro desplegado, todo salvo la landing y los docs exige sesión |
AUTH_SIGNUP_DISABLED | Fijada a 1, rechaza cuentas nuevas (403 SIGNUP_DISABLED) sin tocar a las existentes | Registro abierto |
AUTH_GOOGLE_DISABLED | Fijada a 1, oculta Google sin borrar el cliente | Google activo si hay cliente |
Los umbrales de abuso no se configuran: 5 envíos de código por correo y hora, 20 por IP y hora, 3 intentos por código, 10 verificaciones fallidas por IP cada 15 min, 10 cuentas nuevas por IP y día, y una lista de dominios desechables en server/auth/disposable-domains.ts. Cada rechazo escribe una fila en audit_event (IP hasheada con SCRAPE_HASH_SALT, nunca el correo) y devuelve el mismo error genérico (429 AUTH_THROTTLED). Los contadores viven en el Redis del rate limiter (KV_REST_API_* o UPSTASH_REDIS_REST_*; server/http/redis-env.ts acepta ambos pares, no hace falta copiar nombres); sin Redis, o con Redis caído, caen a un techo por instancia mucho más estrecho, nunca a "sin límite".
Migraciones. El esquema es Drizzle (server/db/schema/*.ts) y las migraciones SQL generadas viven versionadas en server/db/migrations/. Se aplican al final de prebuild:full (bun run db:check && bun run db:migrate) solo cuandoVERCEL_ENV=production o DB_MIGRATE=1: preview y producción comparten una sola DATABASE_URL (sin rama Neon por PR), así que un preview que migrara aplicaría esquema aún no fusionado a producción. Sin DATABASE_URL, o fuera de esa puerta, el paso se omite con un aviso y el build sigue. Con la puerta abierta, un fallo de migración rompe el build. Las migraciones deben ser aditivas y compatibles hacia atrás, porque los deploys de preview corren contra el esquema de producción. En un autoalojamiento:
bun run db:generate --name <cambio> # tras editar server/db/schema/*.ts
bun run db:check # detecta snapshots en conflicto (sin red)
DB_MIGRATE=1 DATABASE_URL_UNPOOLED=postgres://... bun run db:migrateResidencia: el proyecto de referencia corre en AWS São Paulo (sa-east-1); un espejo soberano elige su propia región al crear el proyecto de Neon (o su propio Postgres), y nada en el código asume otra cosa.
Alertas y correo (lado servidor):
| Var(s) | Funcionalidad | Sin ella |
|---|---|---|
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROM | Envío de correo para las confirmaciones de /api/alerts-subscribe y avisos de operador desde /api/contribute y /api/intake (revoke no envía correo), vía Mailgun sobre fetch | alerts-subscribe devuelve un fallback mailto: en lugar de fingir la suscripción |
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAIL | Copia interna de los avisos de suscripción/contribución/intake | Sin copia interna |
ALERTS_WEBHOOKS | El cron del resumen semanal (/api/alerts-digest, Mon 13:00 UTC) despacha el resumen global de anomalías a estas URLs de webhook separadas por comas | El resumen no se despacha a nadie |
CRON_SECRET | Autentica el disparador del cron a POST /api/alerts-digest (Vercel envía Bearer $CRON_SECRET) | Fail-closed: cada POST devuelve 401 — el fan-out queda deshabilitado, no desprotegido. (GET /api/alerts-digest?lang=es|en|pt sigue siendo una vista previa pública y localizada del resumen en cualquier caso.) |
Detección de scraping (lado servidor):
| Var | Funcionalidad | Sin ella |
|---|---|---|
SCRAPE_HASH_SALT | Sal del ip_hash seudónimo (SHA-256 truncado a 16 hex) que acompaña a cada evento scrape_flag en PostHog y que sirve de clave de unión por scraper | En producción el evento se emite sin ip_hash y con un distinct_id literal compartido (scrape:unsalted): la detección no cambia en nada, lo que se pierde es poder agrupar por scraper en PostHog. El código se niega a usar en producción la sal por defecto pública, presente en el repo, porque un hash de IPv4 con sal conocida no es seudónimo (2^32 de preimagen) y es justo lo que se envía a un tercero. Fuera de producción sí usa esa sal, porque ahí no se entrega nada. La alerta a operaciones sigue llevando la IP cruda para triage |
SCRAPE_ALERT_MAX_PER_HOUR | Techo horario de alertas de operaciones salientes (correo Mailgun + fan-out de ALERTS_WEBHOOKS), contando a todos los clientes juntos | Por defecto 20 por hora (un valor no numérico o ≤ 0 recae en 20). El techo recorta solo el aviso: la captura en PostHog nunca se limita, así que bajo una inundación de alertas el operador pierde el ping, nunca el registro |
El resumen semanal de alertas es por webhooks (
ALERTS_WEBHOOKS), no por correo: Mailgun maneja la confirmación de suscripción y los avisos del Fideicomiso de Datos. Ambos existen; no los confundas.
Outreach hop
| Var | Funcionalidad | Sin ella |
|---|---|---|
OUTREACH_REDIRECTS | JSON solo servidor { "<token>": "https://…" } para GET /o/{token} → 302. El token es opaco (UUID o similar). Destino allowlist https: futuros.xyz, docs.futuros.xyz, v2.futuros.xyz. El hop identifica en PostHog con el token como distinct_id (identify_reason=hop_mint) y emite hop_redirect. Deja la cookie first-party futuros_o para re-identificar si el visitante vuelve al producto. No hay API de mint. | Cada token responde 404 — no hay redirect abierto ni enumeración del mapa |
OUTREACH_ROSTER | JSON solo servidor { "<token>": { name?, institution?, email?, campaign?, internal? } }. Escribe propiedades de persona en el hop; el distinct_id sigue siendo el token. internal: true marca testers para el filtro de cuentas de prueba de PostHog. Vive con Data, no en el repo. | El hop identifica solo con outreach_token — sin nombre, correo ni institución |
Forma exacta (un objeto, no un array; claves opacas; valores https allowlist):
{
"550e8400-e29b-41d4-a716-446655440000": "https://docs.futuros.xyz/plataforma/recorrido",
"7c9e6679-7425-40de-944b-e07fc1f90ae7": "https://futuros.xyz/atlas"
}Aterrizaje. El destino guardado no siempre es donde aterriza el visitante. Si resuelve a uno de los shells estáticos de marketing (/, /en, /pt), el hop lo re-apunta a /atlas conservando el idioma del shell (?lang=) y cualquier query que el destino ya traía. Esos tres caminos se sirven directo del filesystem (el postbuild renombra landing.html→index.html), así que la SPA no arranca ahí: ni sesión de cliente ni chat. hop_redirect registra el aterrizaje real y no el destino guardado, lo que vuelve a https://futuros.xyz/ y https://futuros.xyz/atlas el mismo enlace. La reescritura alcanza a todo host de la allowlist menos docs.futuros.xyz, que no tiene /atlas: ahí el destino se respeta tal cual y el token viaja solo en ?o=. El propio shell estático también lee ?o= y la cookie futuros_o, de modo que una visita de vuelta a la raíz se cose a la misma persona en lugar de quedar anónima.
En Vercel: Production env OUTREACH_REDIRECTS = ese JSON en una sola línea; OUTREACH_ROSTER es el mismo patrón (otro objeto). Data añade tokens sin cambio de código. Un token desconocido, malformado o con destino fuera de la allowlist es el mismo 404 (Not found), la respuesta no revela si existen otros tokens. El identify ocurre en el hop de futuros.xyz antes del 302 (token como distinct_id; roster opcional como propiedades). El sitio de docs inicializa el mismo proyecto PostHog (snippet VitePress) y puede re-persistir el token; el hop no depende de eso. No fijar VITE_POSTHOG_KEY. El espejo Caddy de Nivel 1 no ejecuta /o/{token} (cae al shell de la SPA); el hop requiere la función api/o.
Ingesta (solo scripts de build, no se necesita para servir la app): el pipeline de refresco lee claves como OPENAQ_API_KEY, CLOUDFLARE_RADAR_TOKEN, YOUTUBE_API_KEY, REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET, SERPAPI_KEY, más las variables de ajuste EXA_/PERPLEXITY_/GDELT_. Estas importan solo cuando re-ejecutas la ingesta (abajo), nunca para servir un corpus ya precalculado.
El corpus es estático, precalculado en tiempo de build
No hay base de datos en la ruta de servicio. Todo el corpus bajo public/data/ se precalcula en tiempo de build y se entrega como archivos estáticos (los mismos archivos que expone la API Pública v1). Servir Futuros significa servir esos archivos, por eso el Nivel 1 (Caddy sobre dist/) es un espejo de datos completo.
Para refrescar los datos re-ejecutas los scripts de ingesta/precálculo y reconstruyes:
bun run ingest:all # ingesta multi-fuente → parameter-cache + citas + señales
bun run bake # narrativas derivadas, etc.; luego los precálculos específicos que necesites:
bun run bake:scores # índice compuesto → scores.json
bun run bake:metrics # registro de métricas
bun run bake:signals # señales citadas
bun run bake:api # artefactos de la API pública v1
bun run build # tsc + vite; prebuild re-ejecuta bake:health/bake:ledger/bake:api
# más las compuertas de procedencia — cualquier fallo detiene el build(No existe un bake:all agregado; el precálculo está deliberadamente dividido para que un refresco pueda tocar una familia sin re-derivar todo.)
prebuild es la compuerta de honestidad, en orden (según package.json): typecheck de la capa de scripts, la suite completa de bun test, luego los scripts de verificación, build-positions-index --check, check-traced, check-signals, check-source-links, check-pilots, check-citations, check-scores, check-provenance, check-emdash-drift, check-catalog, check-search-index --warn, check-i18n: luego tres pasos de precálculo (bake-data-health, bake-source-ledger, bake-api) sellados por check-api. Cada paso salvo la pasada --warn del índice de búsqueda hace fallar el build: una cifra que perdió su fuente no puede publicarse. Así, una reconstrucción autoalojada preserva el contrato de honestidad por construcción, y un operador soberano puede ejecutar el pipeline completo en la región: corpus precalculado en la región, inferencia en la región, nada sale del host.
Configuración soberana recomendada
- Levanta un gateway de pesos abiertos en la región (LiteLLM frente a vLLM/TGI sirviendo Llama/Qwen/DeepSeek/Mistral) que hable la API Anthropic Messages.
- Despliega la SPA + el corpus precalculado (contenedor del Nivel 1) y las funciones
api/*en infraestructura de la región. - Fija
SOVEREIGN_INFERENCE_URL+SOVEREIGN_INFERENCE_KEY(+_MODEL,_LABEL). El chat muestra la insignia de "modo soberano"; el corpus + las consultas nunca salen de tu jurisdicción. - Añade
UPSTASH_REDIS_REST_*solo si quieres persistir el ledger de confianza / consenso; en caso contrario esas funcionalidades se degradan con honestidad.