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 . .
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 .
docker run -p 8080:8080 -e PORT=8080 futuros
# → http://localhost:8080

Desde 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:

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
/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 restoO 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.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_TOKENConfig 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.tsNenhum 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_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_ENABLEDAbre 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á ligado403 — 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_ENABLEDAbre 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á ligado403 {"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 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_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
KV_REST_API_URL + KV_REST_API_TOKEN ou 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â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_SALTSal 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_SALTSal 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_STOREDefinida 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çoDesligada 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_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 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)FuncionalidadeSem ela
DATABASE_URLString de conexão com pool (PgBouncer) usada pelas funções em tempo de execuçãoFail-closed: todo /api/auth/* responde 503 {"error":"auth_not_configured"}; o resto do site não muda
DATABASE_URL_UNPOOLEDString 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_SECRETAssinatura de sessões e tokens; no mínimo 32 caracteres (openssl rand -base64 32). Um segredo mais curto conta como não definidoFail-closed: o mesmo 503
BETTER_AUTH_URLOrigem pública exata (https://futuros.xyz): o Google rejeita uma redirect_uri que difira em host ou esquemaDerivada 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 ResendO 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 definidaIgual 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 URIO 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_DISABLEDInterruptor de emergência do muro de acesso do middleware (chega na próxima versão). Definida como 1, o site se comporta como hojeCom o muro publicado, tudo menos a landing e os docs exige sessão
AUTH_SIGNUP_DISABLEDDefinida como 1, recusa contas novas (403 SIGNUP_DISABLED) sem tocar nas existentesCadastro aberto
AUTH_GOOGLE_DISABLEDDefinida como 1, esconde o Google sem apagar o clienteGoogle 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:

bash
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:migrate

Residê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)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.)

Detecção de scraping (server-side):

VarRecursoSem ela
SCRAPE_HASH_SALTSal 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 scraperEm 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_HOURTeto horário de alertas de operações enviados (e-mail Mailgun + fan-out de ALERTS_WEBHOOKS), contando todos os clientes juntosPadrã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 ​

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"
}

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:

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.