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:
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 e rodar:
docker build -t futuros .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080Desde a versão com contas (outubro de 2026)
A SPA exige iniciar sessão: o contêiner estático acima serve a interface, mas o muro de acesso (middleware.ts) e /api/auth/* precisam das funções de servidor, de Postgres (Neon ou equivalente) e do e-mail transacional. Veja Contas e acesso. O guia completo de deploy com base de dados e migrações chega com a versão de organizações e acesso de máquina; até lá, um self-host estático só é útil com AUTH_WALL_DISABLED=1 num ambiente privado.
O Caddyfile escuta em :{$PORT:8080}, comprime com gzip/zstd e se comporta, matcher por matcher:
| Matcher | Comportamento |
|---|---|
/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 |
/research/* | igual a /data/* — JSON da biblioteca de pesquisa, regenerado em cada build |
/atlas/… com extensão, /geo/* | max-age=86400, immutable — bundles geográficos estáticos. A regra de /atlas/ é um path_regexp que exige extensão de arquivo, de propósito: /atlas/<ISO3> é uma rota da SPA, não um arquivo, e marcá-la immutable fixava o shell da aplicação um dia inteiro em cada cache; após o próximo deploy ele pedia chunks de /assets/ que já não existem, e nem recarregar resolvia. Um glob não bastaria: no Caddy o * final cruza / e um no meio não, então /atlas/*.json deixaria de fora /atlas/adm1-shapes/MEX.json. Mesma regra do vercel.json. |
/embed/* | remove X-Frame-Options e repete a política CSP inteira com frame-ancestors * como mais uma diretiva: script-src, object-src 'none' e base-uri 'self' continuam valendo nos embeds. Um header do Caddy substitui o campo inteiro, então escrever só frame-ancestors * teria derrubado o resto da política em todo /embed/* (que não é uma subárvore estática, são rotas da SPA) |
| todo o resto | O conjunto completo de headers de produção, idêntico ao do vercel.json: Content-Security-Policy em modo de aplicação (default-src 'self', allowlist de origens 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 negando câmera/microfone/geolocalização, e o header Server removido |
O self-host não é mais fraco que o deploy na Vercel
O Caddyfile envia a mesma postura de segurança da configuração de borda da Vercel, header por header. Antes ele não enviava nenhum dos headers de produção (nem CSP, nem HSTS, nem COOP), então um espelho soberano herdava um site mais fraco do que o que publicamos. As escolhas da política estão documentadas no próprio Caddyfile: 'unsafe-inline' em script-src é a troca honesta de um deploy estático que não consegue gerar um nonce por requisição; 'wasm-unsafe-eval' e cdn.jsdelivr.net são o DuckDB-WASM; as origens de fontes em connect-src são o service worker buscando-as de novo; e o COOP é same-origin-allow-popups (não same-origin) porque a exportação em PDF precisa da relação com o opener. Se você editar um dos dois arquivos, edite o outro: divergir é exatamente a falha que isto corrige.
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.html→spa.html e dist/landing.html→index.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 funções serverless Node (chat, mcp, contribute, revoke, consensus, intake, alerts-subscribe, alerts-digest, o, auth/*: intake 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.exampledo 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 noenv.example: a lista autoritativa é esta aqui.
Build / cliente (embutidas, prefixo VITE_):
| Var | Recurso | Sem ela |
|---|---|---|
VITE_MAPBOX_TOKEN | Config morta, não defina. Nenhum código a lê. O mapa do atlas é SVG offline puro (src/components/MapPane.tsx: sem Mapbox, sem MapLibre, sem token, sem tiles), e o único vestígio é a declaração de tipo em src/vite-env.d.ts | Nenhum efeito: o atlas renderiza igual. O env.example a lista apenas para que um operador que a encontre num projeto herdado saiba que pode apagá-la; uma credencial colada numa variável que ninguém lê é só risco, sem contrapartida |
VITE_POSTHOG_HOST | Override de região do PostHog | Padrão US Cloud |
Analytics em um self-host: a chave do projeto PostHog está hardcoded em
src/lib/analytics.ts(uma variável de envVITE_POSTHOG_KEYé deliberadamente ignorada), e a inicialização é restrita aos hostnamesfuturos.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.
| Var | O que abre | Sem ela |
|---|---|---|
CHAT_ENABLED | Abre POST /api/chat (também precisa de uma chave de provedor e do rate limit do Upstash). O chat no site (SPA) continua gratuito; chamadas programáticas / outras origens precisam de uma chave pré-paga ft_live_… de /acceso quando o lever de acesso está ligado | 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_ENABLED | Abre POST /api/mcp (também precisa do rate limit do Upstash). Quem chama deve apresentar uma chave pré-paga (Authorization: Bearer ft_live_…) quando o lever de acesso está ligado | 403 {"error":"mcp_disabled"}. GET /api/mcp?health=1 continua respondendo e reporta em enabled |
SCRAPE_WRITE_ENABLED | A via de escrita do scraping | Modo 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 20 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_KEY | Caminho 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_MODEL | O served-model-name do seu gateway (padrão local-model) |
SOVEREIGN_INFERENCE_LABEL | Rótulo de UI para o provedor soberano (padrão Soberano) |
SOVEREIGN_INFERENCE_REGION | Declara 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_KEY | Fallback gerenciado: roteia pelo endpoint Anthropic-nativo da OpenRouter (modelo padrão anthropic/claude-sonnet-4.6) |
ANTHROPIC_API_KEY | Chama api.anthropic.com diretamente (padrão claude-sonnet-4-6) |
CHAT_MODEL / CHAT_FOLLOWUP_MODEL | Sobrescreve 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) | Recurso | Sem ela |
|---|---|---|
VOYAGE_API_KEY (+ CHAT_EMBED_MODEL, CHAT_RERANK_MODEL) | search_corpus semântico sobre o índice gerado no build + rerank cross-encoder | Degrada para busca keyword/BM25 (sem falha dura) |
EXA_API_KEY | Fallback web de última instância do chat (web_search/fetch_url) + ingestão de pulso | O 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) | Recurso | Sem ela |
|---|---|---|
KV_REST_API_URL + KV_REST_API_TOKEN ou UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN | Ledger 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âncias. Mesma precedência nos três (KV_* primeiro; ver server/http/redis-env.ts) | Retorna 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_SALT | Sal secreto para o id pseudônimo de contribuidor nos recibos do ledger (contributor_id = sha256(sal:email); o ledger nunca armazena o e-mail bruto; a tabela user das contas armazena, porque o acesso sem senha precisa dele) | 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 |
RATE_LIMIT_KEY_SALT | Sal do HMAC-SHA256 que forma a chave do rate limit por endereço em /api/alerts-subscribe (alerts-subscribe-addr:<digest de 32 hex>), para que nenhum e-mail de inscrito apareça no keyspace do Redis (um keyspace compartilhado por todos os endpoints e visível em qualquer listagem REST ou backup) | Recai em TRUST_ID_SALT e, na falta dele, num sal padrão público, presente no repo. A partição por destinatário é idêntica nos três casos; a única perda é o digest deixar de ser imprevisível para quem já conhece um endereço. O endereço armazenado, para o qual se envia e que volta na resposta continua sendo exatamente o que a pessoa digitou |
RATE_LIMIT_REQUIRE_STORE | Definida como 1, endurece as superfícies pagas (chat, MCP, as que pedem failClosed): "nenhum armazenamento de rate limit configurado" vira falha dura numa implantação gerenciada, e um armazenamento quebrado deixa de cair para o teto degradado. Nos dois casos a resposta é "temporariamente indisponível" em vez de serviço | Desligada por padrão. Sem armazenamento configurado os limites são por instância (ou seja, multiplicados pelo número de instâncias vivas) e só resta um aviso no log. Um armazenamento configurado que passa a falhar nunca volta ao teto completo: sem esta variável cai para um teto por instância bem mais estreito (chat: 6/min por IP, 30/min e 120/h globais; MCP: 10/min por IP); com ela, recusa. É opt-in para que ligar o modo estrito seja um ato deliberado, depois de você saber que o Redis está provisionado, e não um efeito colateral do deploy. Não vale fora de uma implantação gerenciada: bun test, um vite local e o seu próprio self-host continuam fail-open |
TRUST_ID_SALT_PREVIOUS | Rotaçã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 revogabilidade | Rotacionar 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 e os nomes nativos UPSTASH_REDIS_REST_URL / _TOKEN são aceitos pelo ledger do Fundo Fiduciário, o armazenamento de consenso e o rate limiter (mesma precedência: KV_* primeiro). Basta um par; uma implantação só-KV (o caso típico de Marketplace) deixa de deixar os recibos em persisted:false.
Contas: Better Auth + Neon Postgres + AgentMail (server-side):
As contas de usuário (código por e-mail, link mágico; Google opcional e desligado por padrão), as sessões, o histórico de conversas e os itens salvos vivem em Postgres, não em Redis. O código assume Neon (@neondatabase/serverless, driver HTTP, sem pool para aquecer), mas qualquer Postgres 15+ acessível por HTTPS com o protocolo da Neon, ou um Postgres clássico atrás de um proxy compatível, funciona igual: a string de conexão é a única configuração.
| Var(s) | Funcionalidade | Sem ela |
|---|---|---|
DATABASE_URL | String de conexão com pool (PgBouncer) usada pelas funções em tempo de execução | Fail-closed: todo /api/auth/* responde 503 {"error":"auth_not_configured"}; o resto do site não muda |
DATABASE_URL_UNPOOLED | String de conexão direta para as migrações (a Neon documenta que DDL pelo host com pool falha de forma intermitente) | As migrações usam DATABASE_URL; com Neon, defina as duas |
BETTER_AUTH_SECRET | Assinatura de sessões e tokens; no mínimo 32 caracteres (openssl rand -base64 32). Um segredo mais curto conta como não definido | Fail-closed: o mesmo 503 |
BETTER_AUTH_URL | Origem pública exata (https://futuros.xyz): o Google rejeita uma redirect_uri que difira em host ou esquema | Derivada de VERCEL_PROJECT_PRODUCTION_URL em produção e de VERCEL_URL em previews; localmente http://localhost:5173 |
AGENTMAIL_API_KEY (+ AUTH_EMAIL_INBOX, padrão acceso@futuros.xyz) | Envio do e-mail de acesso (código de seis dígitos + link mágico numa única mensagem) via AgentMail; o inbox é o endereço remetente e deve estar num domínio verificado. Tem prioridade sobre o Resend | O endpoint responde igual (nunca revela se o endereço existe), mas nenhum e-mail sai; ficam uma linha [auth] email_failed no log e uma linha email_failed em audit_event |
RESEND_API_KEY (+ AUTH_EMAIL_FROM, padrão Futuros <acceso@futuros.xyz>) | Alternativa ao AgentMail: o mesmo e-mail via Resend; usada só se AGENTMAIL_API_KEY não estiver definida | Igual ao acima quando também não há AGENTMAIL_API_KEY |
VITE_AUTH_GOOGLE (variável de build) + GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET | "Continuar com o Google" (prompt=select_account, vinculação de contas só com Google). Opcional e desligado por padrão: sem VITE_AUTH_GOOGLE=1 não se mostra o botão nem o vínculo em /cuenta. Registre https://<seu-dominio>/api/auth/callback/google como redirect URI | O Google não é oferecido; o acesso por código continua funcionando. Previews da Vercel nunca o oferecem (o Google não aceita curingas *.vercel.app) |
AUTH_WALL_DISABLED | Interruptor de emergência do muro de acesso do middleware (chega na próxima versão). Definida como 1, o site se comporta como hoje | Com o muro publicado, tudo menos a landing e os docs exige sessão |
AUTH_SIGNUP_DISABLED | Definida como 1, recusa contas novas (403 SIGNUP_DISABLED) sem tocar nas existentes | Cadastro aberto |
AUTH_GOOGLE_DISABLED | Definida como 1, esconde o Google sem apagar o cliente | Google ativo quando há cliente |
Os limites de abuso não são configuráveis: 5 envios de código por endereço e hora, 20 por IP e hora, 3 tentativas por código, 10 verificações falhas por IP a cada 15 min, 10 contas novas por IP e dia, e uma lista de domínios descartáveis em server/auth/disposable-domains.ts. Cada recusa grava uma linha em audit_event (IP com hash via SCRAPE_HASH_SALT, nunca o endereço) e devolve o mesmo erro genérico (429 AUTH_THROTTLED). Os contadores vivem no Redis do rate limiter (KV_REST_API_* ou UPSTASH_REDIS_REST_*; server/http/redis-env.ts aceita ambos os pares, sem copiar nomes); sem Redis, ou com Redis fora do ar, caem para um teto por instância bem mais estreito, nunca para "sem limite".
Migrações. O esquema é Drizzle (server/db/schema/*.ts) e as migrações SQL geradas ficam versionadas em server/db/migrations/. Elas se aplicam no fim de prebuild:full (bun run db:check && bun run db:migrate) somente quandoVERCEL_ENV=production ou DB_MIGRATE=1: preview e produção compartilham uma única DATABASE_URL (sem branch Neon por PR), então um preview que migrasse aplicaria esquema ainda não mesclado à produção. Sem DATABASE_URL, ou fora dessa porta, o passo é pulado com um aviso. Com a porta aberta, uma falha de migração quebra o build. As migrações devem ser aditivas e compatíveis para trás, porque os deploys de preview rodam contra o esquema de produção. Em um autoalojamento:
bun run db:generate --name <mudanca> # depois de editar server/db/schema/*.ts
bun run db:check # snapshots em conflito (sem rede)
DB_MIGRATE=1 DATABASE_URL_UNPOOLED=postgres://... bun run db:migrateResidência: o projeto de referência roda em AWS São Paulo (sa-east-1); um espelho soberano escolhe sua própria região ao criar o projeto na Neon (ou seu próprio Postgres), e nada no código assume outra coisa.
Alertas e e-mail (server-side):
| Var(s) | Recurso | Sem ela |
|---|---|---|
MAILGUN_API_KEY + MAILGUN_DOMAIN + MAILGUN_FROM | Entrega 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 fetch | alerts-subscribe retorna um fallback mailto: em vez de fingir a inscrição |
MAILGUN_NOTIFY / ALERTS_NOTIFY_EMAIL | Cópia interna dos avisos de inscrição/contribuição/intake | Sem cópia interna |
ALERTS_WEBHOOKS | O 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írgula | O resumo não é despachado a ninguém |
CRON_SECRET | Autentica 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.) |
Detecção de scraping (server-side):
| Var | Recurso | Sem ela |
|---|---|---|
SCRAPE_HASH_SALT | Sal do ip_hash pseudônimo (SHA-256 truncado em 16 hex) que acompanha cada evento scrape_flag no PostHog e serve de chave de junção por scraper | Em produção o evento sai sem ip_hash e com um distinct_id literal compartilhado (scrape:unsalted): a detecção não muda em nada, o que se perde é agrupar por scraper no PostHog. O código recusa em produção o sal padrão público, presente no repo, porque um hash de IPv4 com sal conhecido não é pseudônimo (2^32 de pré-imagem) e é justamente o que enviamos a um terceiro. Fora de produção ele usa esse padrão, já que ali nada é entregue para fora. O alerta de operações continua carregando o IP bruto para triagem |
SCRAPE_ALERT_MAX_PER_HOUR | Teto horário de alertas de operações enviados (e-mail Mailgun + fan-out de ALERTS_WEBHOOKS), contando todos os clientes juntos | Padrão de 20 por hora (um valor não numérico ou ≤ 0 recai em 20). O teto corta apenas o aviso: a captura no PostHog nunca é limitada, então numa enxurrada de alertas o operador perde o ping, nunca o registro |
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
| Var | Recurso | Sem ela |
|---|---|---|
OUTREACH_REDIRECTS | JSON 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_ROSTER | JSON 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):
{
"550e8400-e29b-41d4-a716-446655440000": "https://docs.futuros.xyz/plataforma/recorrido",
"7c9e6679-7425-40de-944b-e07fc1f90ae7": "https://futuros.xyz/atlas"
}Onde aterrissa. O destino guardado nem sempre é onde a pessoa termina. Se resolver para um dos shells estáticos de marketing (/, /en, /pt), o hop o re-aponta para /atlas, preservando o idioma do shell (?lang=) e qualquer query que o destino já trazia. Esses três caminhos são servidos direto do filesystem (o postbuild renomeia landing.html→index.html), então a SPA não inicia ali: sem sessão de cliente e sem chat. hop_redirect registra a aterrissagem real e não o destino guardado, o que faz de https://futuros.xyz/ e https://futuros.xyz/atlas o mesmo link. A reescrita alcança todo host da allowlist menos docs.futuros.xyz, que não tem /atlas: lá o destino é respeitado como está e o token viaja só em ?o=. O próprio shell estático também lê ?o= e o cookie futuros_o, de modo que uma visita de volta à raiz se costura à mesma pessoa em vez de ficar anônima.
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:
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.
Configuração soberana recomendada
- 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.
- Implante a SPA + corpus gerado no build (container do Nível 1) e as funções
api/*em infraestrutura na região. - Defina
SOVEREIGN_INFERENCE_URL+SOVEREIGN_INFERENCE_KEY(+_MODEL,_LABEL). O chat exibe o selo "modo soberano"; corpus + consultas nunca saem da sua jurisdição. - Adicione
UPSTASH_REDIS_REST_*apenas se quiser o ledger de confiança / consenso persistidos; caso contrário, esses recursos degradam honestamente.