Skip to content

Self-hosting (implantação soberana)

O Futuros foi projetado para ser reimplantado na região. Este é o Pilar 2 (o Modelo Soberano): um governo latino-americano ou um multilateral pode rodar a plataforma inteira — o atlas, o corpus gerado no build, a API pública e o assistente de IA — em infraestrutura dentro da própria jurisdição, apontando a inferência para um gateway de pesos abertos na região, de modo que o corpus e cada consulta de usuário permaneçam em solo soberano.

Como a plataforma é uma SPA estática + corpus JSON gerado no build + um pequeno conjunto de funções serverless, o self-hosting vem em dois níveis.

Dois níveis, um repo

O Dockerfile + Caddyfile do repo produzem uma implantação estática: a SPA, o corpus public/data/ gerado no build, a API Pública v1 e os embeds. Esse container (Caddy servindo dist/) não executa as funções serverless de api/ — então o chat, o servidor MCP, contribuir/revogar do Fundo Fiduciário de Dados, consenso e os endpoints de alertas não são servidos por ele. Para rodar esses serviços interativos você precisa de um host que execute as funções Node api/* (Vercel, ou qualquer runtime Node/functions), mais o env abaixo.

Nível 1 — espelho soberano estático (Docker + Caddy)

O Dockerfile multi-stage incluído constrói com Bun e entrega uma imagem Caddy minúscula servindo o build estático:

dockerfile
FROM oven/bun:1.3 AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
ARG VITE_GATE_PASSWORD                 # opcional; sem ela a SPA não tem gate
ENV VITE_GATE_PASSWORD=${VITE_GATE_PASSWORD}
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 e rodar:

bash
docker build -t futuros --build-arg VITE_GATE_PASSWORD=your-gate-pass .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080

O Caddyfile escuta em :{$PORT:8080}, comprime com gzip/zstd e se comporta, matcher por matcher:

MatcherComportamento
/assets/*Cache-Control: public, max-age=31536000, immutable — bundles com hash
/data/*max-age=600, s-maxage=3600 — o corpus gerado no build renova em minutos após um redeploy
/atlas/*, /geo/*max-age=86400, immutable — bundles geográficos estáticos
/embed/*remove X-Frame-Options, define Content-Security-Policy: frame-ancestors * — embeds cross-origin funcionam a partir de um espelho
tudoX-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy negando câmera/microfone/geolocalização, e o header Server removido

A ordem de resolução é try_files {path} {path}/ /spa.html: um arquivo real sempre vence o fallback da SPA. É por isso que os artefatos JSON gerados no build de /api/v1/* são servidos mesmo no Nível 1 (são arquivos comuns em dist/), enquanto um caminho dinâmico como /api/chat não tem handler ali e cai no shell da SPA. Isso espelha o rewrite do vercel.json de todo caminho sem extensão e não reservado para /spa.html, com uma diferença deliberada: o padrão da Vercel também exclui /api/*, porque na Vercel as funções vivem lá.

O alvo do fallback importa: o postbuild renomeia dist/index.htmlspa.html e dist/landing.htmlindex.html (de modo que /index.html é a landing page, não o shell do app), depois pré-renderiza a landing (scripts/prerender-landing.mjs) e apaga qualquer CLAUDE.md que tenha vazado para dist/. O railway.json conecta o mesmo Dockerfile para a Railway: builder DOCKERFILE, health check /intro (timeout de 30 s), política de restart ON_FAILURE com 3 tentativas.

Este nível é um espelho de soberania de dados completo e capaz de operar offline: a UI completa do atlas e todo o corpus citado, hospedados onde quer que você rode o container. Qualquer coisa que precise de um servidor (abaixo) degrada honestamente — o app continua usável, os recursos interativos anunciam que não estão configurados em vez de simular um resultado.

Nível 2 — plataforma completa (funções serverless + inferência soberana)

As superfícies interativas vivem em api/* como nove funções serverless Node (chat, mcp, contribute, revoke, consensus, intake, alerts-subscribe, alerts-digest, ointake recebe propostas de piloto, pedidos de apresentação e relatos de inconsistência de visitantes, persistindo em uma fila Upstash com notificação via Mailgun, honesta-quando-não-configurada como as demais). Implante-as em um runtime que as execute (a Vercel é o alvo de referência) e defina as variáveis de env abaixo. Os assets estáticos e o corpus são o mesmo build do Nível 1.

Variáveis de ambiente

Agrupadas por recurso. Tudo é opcional — cada chave não definida degrada um recurso específico honestamente (veja a tabela mais abaixo). As chaves de cliente têm prefixo VITE_ e são embutidas no build; o resto é apenas server-side.

Nota: o env.example do repo documenta apenas as chaves de mapa, analytics, chat/retrieval e o sal do ledger de confiança. As chaves de Upstash, de alertas (Mailgun / webhooks) e da Perplexity abaixo são lidas pelo código mas não estão no env.example — a lista autoritativa é esta aqui.

Build / cliente (embutidas, prefixo VITE_):

VarRecursoSem ela
VITE_MAPBOX_TOKENMapa base Mapbox no atlasO fallback MapLibre renderiza fronteiras sobre um fundo escuro
VITE_GATE_PASSWORDGate suave em rotas de preview (não em /atlas, /comparar, /datos-abiertos, /metodologia)Sem gate — não há senha de fallback no código
VITE_POSTHOG_HOSTOverride de região do PostHogPadrão US Cloud

Analytics em um self-host: a chave do projeto PostHog está hardcoded em src/lib/analytics.ts (uma variável de env VITE_POSTHOG_KEY é deliberadamente ignorada), e a inicialização é restrita aos hostnames futuros.xyz / *.futuros.xyz — um espelho soberano em outro domínio emite zero analytics independentemente do env. O site de docs (docs/.vitepress/posthog-snippet.ts) usa a mesma chave hardcoded e o mesmo gate de hostname. Apontar o analytics para o seu próprio projeto PostHog significa editar esses arquivos, não definir uma variável.

Os gates que falham fechados

Antes de qualquer chave de provedor, três interruptores explícitos governam se um endpoint dinâmico sequer existe. Todos falham fechados: com a variável não definida, a superfície devolve uma recusa limpa em vez de ficar aberta. Um .env vazio é, por design, uma postura correta — nunca um vazamento.

VarO que abreSem ela
CHAT_ENABLEDPOST /api/chat público (também precisa de uma chave de provedor e do rate limit do Upstash)403 — o assistente não responde. Este é o passo mais esquecido ao auto-hospedar: com ANTHROPIC_API_KEY configurada mas sem este interruptor, o chat continua devolvendo 403
MCP_ENABLEDPOST /api/mcp público (também precisa do rate limit do Upstash)403 {"error":"mcp_disabled"}. GET /api/mcp?health=1 continua respondendo e reporta em enabled
SCRAPE_WRITE_ENABLEDA via de escrita do scrapingModo somente-observação: as escritas são rejeitadas

Cada interruptor deve valer literalmente a string "true".

Esses três vivem junto aos demais em server/http/env-levers.ts, a lista canônica de 15 alavancas de produção (cada uma com seu id, as variáveis que a compõem, o que abre e uma sonda pública para verificá-la). Essa lista é servida em GET /api/env-levers e publicada em /confianza, de modo que um operador — ou um jornalista — pode auditar a postura de um deploy sem acesso ao seu ambiente. leverStatus() reporta apenas um booleano set por alavanca: nunca o valor do segredo.

Assistente de IA — provedor de inferência (server-side; o primeiro match vence):

Var(s)Recurso
SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEYCaminho soberano do Pilar 2. Aponta o mesmo loop de agente Anthropic-Messages para um gateway de pesos abertos na região / self-hosted (LiteLLM, ou vLLM/TGI atrás de um shim compatível com Messages, servindo Llama/Qwen/DeepSeek/Mistral). Quando ambas estão definidas, elas vencem OpenRouter/Anthropic e o chat mostra um selo de "modo soberano" — a consulta + o corpus nunca saem do host.
SOVEREIGN_INFERENCE_MODELO served-model-name do seu gateway (padrão local-model)
SOVEREIGN_INFERENCE_LABELRótulo de UI para o provedor soberano (padrão Soberano)
SOVEREIGN_INFERENCE_REGIONDeclara a região do gateway; a única maneira de o selo afirmar a alegação mais forte de residência "na região"
OPENROUTER_API_KEYFallback gerenciado: roteia pelo endpoint Anthropic-nativo da OpenRouter (modelo padrão anthropic/claude-sonnet-4.6)
ANTHROPIC_API_KEYChama api.anthropic.com diretamente (padrão claude-sonnet-4-6)
CHAT_MODEL / CHAT_FOLLOWUP_MODELSobrescreve os ids de modelo de resposta / follow-up para o provedor ativo

Os provedores formam uma cadeia ordenada de failover: soberano → OpenRouter → Anthropic. O primeiro provedor configurado é o primário; cada chave adicional se torna um fallback transparente para o qual o handler troca no meio da requisição se o primário falhar (os ids de modelo são re-resolvidos por provedor; CHAT_MODEL / CHAT_FOLLOWUP_MODEL vinculam-se apenas ao primário). Uma chave funciona bem; uma segunda compra redundância. Sem nenhuma definida, o chat emite um evento not_configured e declina em vez de dar erro. SOVEREIGN_INFERENCE_REGION (opcional) declara a região geográfica do gateway — é a única maneira de o chat afirmar a alegação mais forte de "na região" por trás do selo soberano; a residência nunca é inferida da mera presença de um endpoint.

Assistente de IA — retrieval e web externa (server-side):

Var(s)RecursoSem ela
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL)search_corpus semântico sobre o índice gerado no build + rerank cross-encoderDegrada para busca keyword/BM25 (sem falha dura)
EXA_API_KEYFallback web de última instância do chat (web_search/fetch_url) + ingestão de pulsoO chat responde apenas a partir do corpus e declina perguntas fora do corpus
PERPLEXITY_API_KEY (+ PERPLEXITY_MODEL)Síntese web ao vivo perplexity_search (chat + MCP)A ferramenta retorna uma nota de "não configurado"; as respostas permanecem só-corpus

Fundo Fiduciário de Dados (Pilar 1) e consenso — Upstash Redis (server-side):

Var(s)RecursoSem ela
UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKENLedger de confiança append-only (recibos de /contribuir, /api/revoke, /api/intake), armazenamento de votos de consenso (/api/consensus) e rate limiting de API entre instânciasRetorna um recibo válido com persisted:false; o consenso degrada para local-only; os rate limits recaem em memória por instância — nunca simula persistência
TRUST_ID_SALTSal secreto para o id pseudônimo de contribuidor nos recibos do ledger (contributor_id = sha256(sal:email); o e-mail bruto nunca é armazenado)Recai no sal padrão público, presente no repo: qualquer um pode recomputar o hash e confirmar se um dado e-mail corresponde a um registro do ledger. Defina um segredo de alta entropia em produção
TRUST_ID_SALT_PREVIOUSRotação do sal: mova o valor antigo para cá (separados por vírgula, se vários); a revogação casa cada promessa antiga com seu sal original pelo rótulo público salt_version, então rotacionar nunca quebra a revogabilidadeRotacionar TRUST_ID_SALT sem reter o sal antigo deixa irrevogáveis as promessas escritas sob ele

Os aliases de Vercel KV KV_REST_API_URL / KV_REST_API_TOKEN são aceitos apenas pelo armazenamento de consenso e pelo rate limiter — o ledger do Fundo Fiduciário de Dados lê exclusivamente UPSTASH_REDIS_REST_URL/_TOKEN. Uma implantação configurada só com os aliases KV_* obtém consenso funcional mas recibos de confiança persisted:false; defina os nomes nativos para cobertura completa.

Alertas e e-mail (server-side):

Var(s)RecursoSem ela
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROMEntrega de e-mail para confirmações de /api/alerts-subscribe e avisos ao operador de /api/contribute e /api/intake (revoke não envia e-mail), via Mailgun sobre fetchalerts-subscribe retorna um fallback mailto: em vez de fingir a inscrição
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAILCópia interna dos avisos de inscrição/contribuição/intakeSem cópia interna
ALERTS_WEBHOOKSO cron do resumo semanal (/api/alerts-digest, Mon 13:00 UTC) despacha o resumo global de anomalias para essas URLs de webhook separadas por vírgulaO resumo não é despachado a ninguém
CRON_SECRETAutentica o gatilho de cron para POST /api/alerts-digest (a Vercel envia Bearer $CRON_SECRET)Fail-closed: todo POST retorna 401 — o fan-out é desativado, não desprotegido. (GET /api/alerts-digest?lang=es|en|pt permanece uma prévia pública e localizada do resumo de qualquer forma.)

O resumo semanal de alertas é baseado em webhooks (ALERTS_WEBHOOKS), não em e-mail — o Mailgun cuida da confirmação de inscrição e dos avisos do Fundo Fiduciário de Dados. Ambos existem; não os confunda.

Outreach hop

VarRecursoSem ela
OUTREACH_REDIRECTSJSON só servidor { "<token>": "https://…" } para GET /o/{token} → 302. O token é opaco (UUID ou similar). Destino allowlist https: futuros.xyz, docs.futuros.xyz, v2.futuros.xyz. O hop identifica no PostHog com o token como distinct_id (identify_reason=hop_mint) e emite hop_redirect. Grava o cookie first-party futuros_o para re-identificar se a pessoa voltar ao produto. Sem API de mint.Cada token responde 404 — sem open redirect e sem enumerar o mapa
OUTREACH_ROSTERJSON só servidor { "<token>": { name?, institution?, email?, campaign?, internal? } }. Grava propriedades de pessoa no hop; o distinct_id continua sendo o token. internal: true marca testers para o filtro de contas de teste do PostHog. Fica com Data, não no repo.O hop identifica só com outreach_token — sem nome, e-mail ou instituição

Forma exata (um objeto, não um array; chaves opacas; valores https na allowlist):

json
{
  "550e8400-e29b-41d4-a716-446655440000": "https://docs.futuros.xyz/plataforma/recorrido",
  "7c9e6679-7425-40de-944b-e07fc1f90ae7": "https://futuros.xyz/atlas"
}

Na Vercel: env de Production OUTREACH_REDIRECTS = esse JSON numa linha; OUTREACH_ROSTER é o mesmo padrão (outro objeto). Data acrescenta tokens sem mudança de código. Token desconhecido, malformado ou com destino fora da allowlist é o mesmo 404 (Not found) — a resposta não revela se existem outros tokens. O identify corre no hop de futuros.xyz antes do 302 (token como distinct_id; roster opcional como propriedades). O site de docs inicia o mesmo projeto PostHog (snippet VitePress) e pode re-persistir o token; o hop não depende disso. Não definir VITE_POSTHOG_KEY. O espelho Caddy do Nível 1 não executa /o/{token} (cai no shell da SPA); o hop exige a função api/o.

Ingestão (apenas scripts de build — não é preciso para servir o app): o pipeline de refresh lê chaves como OPENAQ_API_KEY, CLOUDFLARE_RADAR_TOKEN, YOUTUBE_API_KEY, REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET, SERPAPI_KEY, mais as vars de ajuste EXA_/PERPLEXITY_/GDELT_. Elas importam apenas quando você re-executa a ingestão (abaixo), nunca para servir um corpus já gerado.

O corpus é estático, gerado em tempo de build

Não há banco de dados no caminho de serviço. Todo o corpus sob public/data/ é gerado em tempo de build e entregue como arquivos estáticos (os mesmos arquivos que a API Pública v1 expõe). Servir o Futuros significa servir esses arquivos — e é por isso que o Nível 1 (Caddy sobre dist/) é um espelho de dados completo.

Para atualizar os dados, você re-executa os scripts de ingestão/bake e reconstrói:

bash
bun run ingest:all     # ingestão multi-fonte → parameter-cache + citações + sinais
bun run bake           # narrativas derivadas etc.; depois os bakes específicos que você precisar:
bun run bake:scores    #   índice composto → scores.json
bun run bake:metrics   #   registro de métricas
bun run bake:signals   #   sinais citados
bun run bake:api       #   artefatos da API pública v1
bun run build          # tsc + vite; o prebuild re-executa bake:health/bake:ledger/bake:api
                       # mais os gates de procedência — qualquer falha para o build

(Não existe um bake:all agregado; o bake é deliberadamente dividido para que um refresh possa tocar uma família sem re-derivar tudo.)

O prebuild é o gate de honestidade, em ordem (do package.json): typecheck da camada de scripts, a suíte completa bun test, depois os scripts de verificação — 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 — depois três re-bakes (bake-data-health, bake-source-ledger, bake-api) selados por check-api. Todo passo, exceto a passada --warn do search-index, derruba o build — um valor que perdeu sua fonte não pode ser publicado. Assim, uma reconstrução self-hosted preserva o contrato de honestidade por construção, e um operador soberano pode rodar o pipeline inteiro na região: corpus gerado na região, inferência na região, nada sai do host.

  1. Levante um gateway de pesos abertos na região (LiteLLM na frente de vLLM/TGI servindo Llama/Qwen/DeepSeek/Mistral) que fale a Anthropic Messages API.
  2. Implante a SPA + corpus gerado no build (container do Nível 1) e as funções api/* em infraestrutura na região.
  3. Defina SOVEREIGN_INFERENCE_URL + SOVEREIGN_INFERENCE_KEY (+ _MODEL, _LABEL). O chat exibe o selo "modo soberano"; corpus + consultas nunca saem da sua jurisdição.
  4. Adicione UPSTASH_REDIS_REST_* apenas se quiser o ledger de confiança / consenso persistidos; caso contrário, esses recursos degradam honestamente.

Cada número com sua fonte — a rastreabilidade é o contrato.