Skip to content

feat: identidade visual, convergência dos irmãos e desafio interativo de login - #10

Merged
elielsousa-pathbit merged 35 commits into
masterfrom
feat/identidade-e-padronizacao
Sep 14, 2026
Merged

elielsousa-pathbit merged 35 commits into
masterfrom
feat/identidade-e-padronizacao

Conversation

@elielsousa-pathbit

Copy link
Copy Markdown
Contributor

O que muda

  • Convergência dos irmãos: sincronização do canone dos 3 gateways (9RTKSync, LiteLlmRTKSync, OminiRTkSync) com suíte de testes de invariância idêntica (test_irmaos_identicos.py).
  • Desafio Interativo Anti-Robô: substituição da prova de trabalho legada (PoW) por desafio visual direto de 1 clique (4 opções de ícones), sem congelamento de aba e sem uso de CPU.
  • Identidade visual e painéis: padronização de rodapés, cabeçalhos, telas de login, rotas e catálogos i18n em EN/PT/ES.
  • Configurações de editor: diretiva git.ignoredRepositories para evitar ruído de clones em tmp/ no Source Control.

Validação

  • 427 testes passando (621 subtestes) com 100% de sucesso.
  • Catraca de simetria dos três irmãos aprovada.

…ndo de assustar

**Nomes.** Containers no padrao <prefixo>rtk-<papel>: 9rtk-router/9rtk-sync,
ominirtk-router/ominirtk-sync, litellmrtk-db/router/sync, mais os da bancada.
O nome do PROJETO passa a ser o do nosso produto; antes dois levavam o nome do
gateway, o que fazia a stack parecer do upstream. Chave de servico, imagem,
volume e URL interna ficaram intactas -- so container_name mudou, porque o DNS
da rede resolve pelo servico.

**Portas.** O claudegravity dos artigos fica com a 20128 (padrao do 9Router) e
o painel dele saiu da 9091 para a 9190: a faixa 909x e das stacks dos
repositorios. Agora o artigo e os tres sincronizadores sobem juntos, que e o
caso de quem escreve o artigo com os projetos ao lado.

**Permissao no volume compartilhado -- o gateway nao subia.** O sincronizador
roda como root e cria db/ e logs/ no startup; esses diretorios nasciam com dono
root e o gateway, que roda como `node` (1000), perdia a escrita no proprio
volume. O sintoma era a tela de login do 9Router recusando com
"EACCES: permission denied, mkdir /app/data/db/backups". O servico passa a
declarar user: "1000:1000". Verificado subindo a stack do zero apos down -v.

**Gateway recem-subido deixa de ser erro.** Enquanto ninguem cadastra a
primeira conexao, o gateway nao criou o banco -- estado normal de quem acabou
de instalar. O ciclo relatava `db_not_found` como falha, e o painel abria com
"ERRO" em vermelho no primeiro minuto de uso. Pior que o susto: ensina o
operador a ignorar o indicador de erro, que precisa continuar significando algo
quando quebrar de verdade. Agora e um estado de espera anunciado, com o resumo
na forma que a tela espera. Tres testes por repositorio.

**Teste que quebrava na maquina de quem seguia a documentacao.** Settings
carrega um .env do diretorio quando existe, e o fluxo documentado (`make setup`)
cria exatamente esse arquivo -- a suite passava a ler a senha real de quem
configurou a propria stack. Os testes de politica de senha agora apontam para um
caminho inexistente, que e a forma de dizer "so o ambiente".

**Identidade visual.** Cada sincronizador tem icone proprio, escolhido pelo que
ele faz: raio (9RTKSync, o caminho rapido), bifurcacao (OminiRTkSync, distribui
entre rotas) e medidor (LiteLlmRTKSync, confere teto). A marca deixou de ser
gradiente de duas cores e virou icone branco sobre um tom do tema -- assim quem
identifica o produto e a forma, e a cor fica por conta do tema. Favicon SVG
embutido por produto: o /favicon.ico do painel responde 401, entao sem isso a
aba ficava com o icone generico.

**Grid.** A tabela de conexoes tem sete colunas e nenhuma largura declarada: o
navegador dava a menor fatia justamente ao diagnostico, a coluna de frase mais
longa, que quebrava em quatro linhas. Agora a divisao e declarada em <colgroup>
com table-layout fixed, o identificador longo do provedor quebra dentro da
celula, e em tela estreita a tabela rola em vez de espremer.

**LiteLLM.** A UI dele aceita a MASTER_KEY como senha quando UI_USERNAME e
UI_PASSWORD nao existem -- ou seja, para ver um painel o operador digitava a
credencial que administra a instalacao inteira. As duas variaveis entraram nos
composes.
Na rede default do mesmo daemon, duas stacks resolvem o mesmo nome curto:
"http://9router:20128" apontava tanto para o gateway deste repositorio
quanto para o da stack do artigo, e o painel nao tinha como dizer a qual
deles estava conectado. Agora servico, container_name e hostname carregam
o mesmo nome, e cada stack sobe na sua propria rede nomeada.

Tres coisas que a renomeacao sozinha nao resolveria:

- o .env fixava LITELLM_URL=http://litellm:4000, sobrepondo o default do
  compose -- era esse valor que aparecia no painel;
- NEXT_PUBLIC_BASE_URL anunciava localhost:20128, que no host e a porta da
  stack do artigo, mandando o fluxo de login para o gateway errado;
- as tres bancadas de egress nasceram com 172.31.0.0/24 e as mesmas portas,
  entao subir duas ao mesmo tempo falhava com "Pool overlaps". Cada repo
  passa a ter a sua faixa (172.31/172.32/172.33) e as suas portas.
O gateway pode guardar a credencial cifrada em repouso, no formato
enc:v1:<iv>:<cifra>:<tag>. Lendo o campo cru do banco, a sonda mandava o
texto cifrado ao provedor, levava a recusa esperada e concluia
"credencial invalida" -- e gravava isso de volta no banco do gateway.

Medido no irmao OminiRTkSync: as cinco conexoes exibiam "invalid" e
"Erro upstream HTTP 400" na tela do proprio gateway, inclusive uma cuja
validade so venceria 13 minutos depois. Nada havia sido testado; o
vermelho era nosso, e pedia ao operador exatamente a acao errada --
reautenticar uma conta que ninguem provou estar ruim.

Aqui a mesma guarda entra por simetria: os dois sincronizadores
compartilham este arquivo praticamente palavra por palavra, e o 9Router
tambem cifra em repouso quando STORAGE_ENCRYPTION_KEY esta configurada.
Um volume nomeado montado sobre um diretorio que nao existe na imagem
nasce com dono root. Como o sincronizador roda como uid 1000 (para
dividir o volume do gateway sem estragar as permissoes dele), ele perdia
a escrita do proprio log. Medido no irmao OminiRTkSync:
"[Errno 13] Permission denied: '/app/logs/ominirtksync.log'".
…orno

O painel le o banco do gateway e mostra a saude das credenciais. Todos os
composes deste repo publicam a porta dele em 127.0.0.1 e a wiki diz, em
Remote-Access.md, para manter assim. Quem escapava era o Makefile:
"-p 9091:9090" publica em TODA interface -- o Wi-Fi do cafe, a VLAN do
escritorio. O LiteLlmRTKSync ja havia sido endurecido e os irmaos nao.

A divergencia sobreviveu porque o teste que deveria pega-la nunca leu o
Makefile: test_portas_documentadas.py varre so docker-compose*.yml e
*.md. A guarda nova le o Makefile e reprova qualquer "-p" sem 127.0.0.1,
alem de conferir que a porta publicada e a deste repositorio e nao a do
irmao.

Junto, duas divergencias que este repo nao recebeu no passe de
isolamento: a rede da bancada de egress agora declara `name:` em vez de
deixar o Docker deriva-lo do diretorio (os dois irmaos ja faziam), e o
alvo do workflow de limpeza de pacotes apontava para "9rtksyncatest",
nome que nao casa com imagem nenhuma -- a limpeza nunca limpou.

243 testes verdes.
Espelha a guarda criada no OminiRTkSync, onde o --help anunciava
"(padrao: pathbit)" em arquivo versionado. Aqui nao ha o defeito hoje;
a guarda existe para que a proxima vez nao dependa de um cetico
encontrar por acaso, procurando outra coisa.
A pergunta "somos doze devs, quantas assinaturas eu compro?" nao tem
resposta publicada: nenhuma das assinaturas de consumo declara a
capacidade absoluta de um assento -- publicam multiplicador e janela. Uma
tabela dizendo "1 licenca cobre 4 devs" so poderia sair de inventar o
numero que ninguem divulga, e o numero inventado seria repetido por anos
por quem nao tem como conferir.

A pagina faz as tres coisas que dao para fazer com honestidade: da a
formula com cada variavel nomeada, preenche o lado da demanda com o que
foi medido, e da o comando que acha o divisor que falta na instalacao do
leitor.

Tres ceticos independentes reprovaram a primeira versao, e com razao. O
que eles derrubaram, e que esta corrigido aqui:

- a pagina afirmava que os scripts estavam versionados, e nenhum estava.
  Por isso este commit e conjunto: pagina e scripts juntos, ou a frase
  volta a ser falsa no instante seguinte.
- numeros creditados a "contagem direta" -- que nao e comando nem script.
  Agora cada quantidade tem script que a reproduz, ou esta marcada como
  arbitrada, ou virou "meca assim: <comando>".
- o fator de concorrencia c=0,6 era citado como se viesse do script,
  sendo uma constante escolhida a dedo dentro dele. Fonte circular
  declarada como tal.
- afirmacoes de schema que o proprio banco desmentia.
- fonte apontando para caminho de disco de outro repositorio, que o
  leitor da wiki publicada nao tem.

Um cetico de segunda passada refez os comandos de cada achado e confirmou
a morte de cada um. Os que sobreviveram a essa segunda passada estao
corrigidos neste commit.
"Os frontends devem ter os mesmos componentes, mas as cores mudam" so se
sustenta se a diferenca entre os paineis estiver inteiramente nos VALORES
de um conjunto fechado de tokens, nunca na existencia deles.

A guarda fixa nove tokens de papel cromatico, que mudam por produto, e
doze estruturais, identicos nos tres. Reprova token faltando, token
sobrando, valor estrutural divergente, fundo trocado, dois papeis com a
mesma cor e escada de profundidade fora de ordem.

O espelho --brand-b = --accent fica declarado como intencional: ele
existe nos tres de proposito, porque o gradiente da marca termina na cor
de destaque. Colisao que existe nos tres e convencao; a que existe em um
e descuido -- e foi assim que a guarda achou a marca do LiteLlm valendo o
mesmo que a borda dele.
O cabecalho Server ia na primeira linha de toda resposta, inclusive no
401 que sai antes de qualquer autenticacao, logo acima da CSP e do
X-Frame-Options que o resto do cabecalho instala -- a mesma resposta que
fecha as portas dizia qual e a fechadura. Versao exata do interpretador
e o que um scanner precisa para escolher o exploit certo, e nada no
produto depende de publica-la.

version_string() tambem e sobrescrito: o BaseHTTPRequestHandler concatena
server_version + " " + sys_version, entao com sys_version vazio a
resposta saia com um espaco sobrando no fim do valor.

Junto, a guarda que reprova chave de traducao usada e nao declarada.
Ela nao tinha defeito para achar aqui; entra por simetria, porque o
irmao OminiRTkSync servia "egress.title" cru na tela e ninguem percebeu
ate alguem abrir o painel.
O SVG do produto ja estava no modulo, mas so na pagina de aviso. O
DASHBOARD -- a aba que o operador deixa aberta o dia inteiro, e a unica
que ele realmente olha -- nao declarava icone nenhum, entao o navegador
tentava /favicon.ico, levava 401 do Basic Auth e desenhava o quadrado
generico. Com os tres sincronizadores abertos lado a lado, as tres abas
ficavam indistinguiveis.

O icone vira constante usada pelos dois documentos que o modulo serve, em
vez de literal repetido -- duas copias e como elas divergem. Continua
sendo data URI de proposito: qualquer URL seria buscada, e a busca leva
401 antes de o operador autenticar.

A guarda conta os <head> do modulo e exige uma declaracao de icone para
cada um, entao uma pagina nova nasce com icone ou o teste reprova.
DUAS PORTAS PARA A MESMA CASA

O painel nasceu so com Basic Auth, e o dialogo que o navegador abre para
isso e janela DELE, nao pagina nossa: nao se traduz, nao se estiliza, nao
tem logout -- a unica forma de sair era fechar o navegador -- e nao e
HTML, entao qualquer ferramenta que dirija um navegador para ali, porque
nao ha campo para preencher. Foi assim que a verificacao visual destes
paineis travou.

Agora existe um formulario, com a casca e a paleta de cada produto, que
entrega um cookie assinado (HMAC, oito horas, HttpOnly, SameSite=Strict).
O segredo nasce a cada processo, em memoria: reiniciar invalida as
sessoes, que e a escolha certa para um painel que le credenciais. O Basic
Auth continua aceito, porque e ele que faz curl e monitoramento
funcionarem sem sessao -- quem pede HTML vai para o formulario, quem nao
pede recebe o 401 de sempre.

O nome do cookie carrega o prefixo do produto porque cookie nao se separa
por porta: com nome generico, entrar num painel derrubaria a sessao dos
outros dois no mesmo 127.0.0.1.

O GRID QUE NAO CABIA

Com table-layout:fixed a largura da coluna e lei, e text-nowrap sem
overflow:hidden nao corta nem quebra: o excesso se desenha POR CIMA da
coluna vizinha. Era por isso que a validade aparecia escrita sobre a data
de renovacao. Tres mudancas, e so as tres juntas resolvem:

- corte com reticencias, para o excesso parar na celula;
- larguras redistribuidas: "Nome" tinha width:auto e engolia o espaco que
  sobrava, deixando "Chave de API" virar "Chave de ..." e o cabecalho
  "Detalhes" quebrar em "Detalhe" + "s";
- textos encurtados na celula, com a explicacao no modal: a data perde
  ano e segundos (13/09 18:44), e a validade perde o parentese. A coluna
  diz o fato; o botao (i) da linha diz a razao.

No cartao do agendador, "Ultimo resultado" era a unica linha que
empilhava o valor embaixo do rotulo, enquanto as duas acima punham o
valor a direita; e a data completa nao cabia em col-6 e quebrava no meio
de "UTC", virando "U C" na tela.

O favicon tambem entra aqui: a pagina de login e a terceira que o modulo
serve, e a guarda conta os <head> para que nenhuma nasca sem icone.
…trabalho

Preso ao loopback o painel nao precisa disso. Atras de um tunel precisa --
e o tunel e um botao que o operador aperta quando quiser, entao o freio
tem de ja estar instalado quando ele apertar.

Tres camadas, da mais barata para a mais cara:

1. Teto por janela. Mais de dez tentativas no mesmo endereco em cinco
   minutos devolve 429 com Retry-After. E o que para o script que tenta
   mil senhas por minuto. O teto e POR ENDERECO: contar global deixaria
   um unico atacante derrubar o acesso de todo mundo.

2. Espera que cresce. Cada falha seguida dobra o atraso da resposta
   seguinte, ate um teto -- sem teto, o proprio processo ficaria preso
   segurando respostas. O atraso e do lado do servidor: nao ha nada no
   cliente para desligar. Acertar a senha limpa a suspeita.

3. Prova de trabalho, depois de tres falhas. O navegador acha um numero
   cujo SHA-256 comeca com quatro zeros; conferir isso aqui e instantaneo.
   Sem conta, sem servico externo, sem cookie de rastreio e sem imagem
   para decifrar: o custo cai sobre quem tenta em massa, nao sobre quem
   esqueceu a senha. O desafio e de uso unico -- sem consumir, um bot
   resolveria uma vez e repetiria a resposta para sempre.

Junto, robots.txt servido antes da sessao: um rastreador nao tem
credencial, e a unica forma de ele ler a regra e ela nao exigir uma.

Medido contra o painel no ar: dez tentativas com senha errada passam, a
decima primeira responde 429.
A BARRA

Ficou: idioma, "Sincronizar agora", "Sair" -- nesta ordem, todos com
texto, nos tres paineis.

"Atualizar" saiu porque recarregar a pagina e rodar o ciclo viraram um
botao so: dois botoes vizinhos que parecem fazer a mesma coisa obrigam o
operador a escolher no escuro. "Acesso" saiu da barra e a troca de senha
foi para o rodape, ao lado de quem esta autenticado -- trocar a propria
senha nao e uma acao de sincronizacao e nao pertencia ali. O cartao do
agendador ganhou "Executar agora", que e de onde se quer forcar uma
rodada de validacao sem subir ate o topo.

O CARIMBO QUE SE ESTRAGAVA SOZINHO

Na tela aparecia "2026-09-13 19:08:48 U C", e parecia defeito de CSS --
dava para jurar que a coluna era estreita e a palavra quebrava. Nao era:
format_timestamp trocava TODO "T" da string por espaco. Aplicada uma vez
acertava; aplicada sobre o proprio resultado, comia o "T" de "UTC". Agora
troca apenas o separador da posicao 10, e o teste formata duas vezes para
garantir que continue assim.

O AVISO QUE NAO SUMIA

O retorno de cada acao viaja na querystring (POST-Redirect-GET, para o F5
nao repetir a acao), e ficava: a URL guardava o texto e recarregar trazia
de volta a mensagem de algo que ja tinha acontecido. Agora a querystring
e limpa do historico assim que a pagina desenha, sem nova requisicao, e o
aviso some sozinho depois de alguns segundos.

Junto: o favicon passa a usar o MESMO glifo do Bootstrap Icons que a
marca do cabecalho desenha, em vez de um SVG parecido feito a mao -- a
aba e o painel eram reconheciveis como coisas diferentes. As colunas
"Ultima renovacao" (que cortava o proprio titulo por sete pixels) e a do
cartao do agendador ganharam a largura que o conteudo pede.

287 / 313 / 179 testes verdes.
O painel le o banco do gateway e mostra a saude das credenciais. Uma rota
que escape da exigencia de sessao entrega isso a quem chegar -- e o jeito
de uma rota escapar nao e alguem decidir abri-la, e alguem acrescentar
uma rota nova e esquecer de protege-la.

A guarda le o despacho do servidor e cobra o inverso do usual: toda rota
e fechada, menos quatro, cada uma com o motivo escrito no proprio teste.
/healthz porque o healthcheck do Docker roda sem credencial; /login
porque exigir sessao para exibir o formulario que cria a sessao e um
circulo fechado; /robots.txt porque um rastreador nao tem como
autenticar e a regra so serve se ele conseguir le-la; e
/credenciais-atualizadas porque e servida no instante seguinte a troca de
senha, quando o navegador ainda guarda a anterior.

Cobre tambem o POST, que muda estado, e confere que a sessao e exigida
ANTES de o corpo ser lido -- ler primeiro e aceitar carga de quem nao
entrou.

Medido contra os tres paineis no ar: as treze rotas conhecidas respondem
401 sem credencial, exceto as quatro publicas.
Um cetico da rodada de integracao encontrou: a lista de "credenciais de
fabrica" do proprio teste trazia a senha REAL desta instalacao, escrita a
mao, e estava versionada nos tres repositorios. Um teste criado para
impedir credencial em arquivo versionado carregando uma -- o tipo de
ironia que passa despercebido por meses.

A lista agora so tem senhas de fabrica publicas dos gateways. O que
protege a senha de verdade e mais forte do que a lista era: o teste LE o
.env (gitignored) em tempo de execucao e varre tudo o que o git rastreia
atras daquele valor. Assim vale para a senha que o operador escolher,
qualquer que seja, e nao so para as que alguem lembrou de escrever aqui.

Varre o que o git rastreia, e nao o disco: e o que sai daqui quando
alguem clona ou publica. Onde nao ha git -- o conteiner de teste do
LiteLlm nao traz -- o teste pula em vez de medir a coisa errada, porque
sem a lista do que e versionado ele acusaria justamente o .env, que deve
mesmo conter a senha.

Junto: `make setup` passa a criar a rede de inferencia. Com external:
true o compose se recusa a subir enquanto ela nao existir, e a mensagem
("network declared as external, but could not be found") nao diz o que
fazer -- num clone limpo isso trava o primeiro up.
O dono nao conseguia conectar o LiteLLM ao 9Router nem ao OmniRoute.
Eram duas causas, e a primeira foi criada por nos.

REDE. Cada stack ficou na sua propria rede -- a pedido, porque antes duas
stacks resolviam o mesmo nome curto e ninguem sabia a qual gateway o
painel falava. O efeito colateral e que o LiteLLM nao resolvia nem o nome
dos irmaos. Nao desfizemos o isolamento: criamos uma SEGUNDA rede, so de
inferencia, a que os tres gateways se conectam alem da rede de gestao.
Gestao isolada, trafego compartilhado. Medido depois: cada sincronizador
continua enxergando apenas o gateway da propria stack.

AUTENTICACAO. Os dois gateways exigem chave em /v1/*, apesar de
REQUIRE_API_KEY=false -- o bypass de loopback nunca dispara sob Docker.

O TESTE QUE MENTIA. A inferencia do OmniRoute responde 200 sem credencial
nenhuma, entao um chat bem-sucedido ali nao prova que a chave vale. Ao
recriar as stacks do zero as chaves morreram junto com os volumes, e o
--testar aprovou o OmniRoute com credencial invalida enquanto acusava a
master key do LiteLLM pelo 401 do 9Router. Agora a credencial e conferida
ANTES, contra /v1/models, que e a rota que os dois protegem -- e a
mensagem nomeia a chave recusada em vez de culpar a errada.

Mais tres defeitos que so aparecem recomeçando do zero:

- a credencial nomeada dentro do LiteLLM sobrevive ao volume do gateway,
  guardando a chave que morreu; passa a ser reescrita a cada execucao;
- o id do modelo era fixo por gateway, entao renomear colidia e o proxy
  respondia "Failed to add model to db" sem dizer o que colidiu. O id
  deriva do nome;
- o modelo de teste do 9Router usava Antigravity, que entra por OAuth e
  vive no volume: apagar o volume exige reconectar pela tela. Provedor
  por chave de API se recadastra por script, e por isso serve de teste.

Prova, com tudo recriado do zero: 9Router responde PROVA-9ROUTER com
`OUT 6` no log casando com `saida=6` da resposta; OmniRoute responde
PROVA-OMNIROUTE com o POST registrado no mesmo instante.

`make setup` cria a rede de inferencia, que e externa e por isso precisa
existir antes do primeiro `up`.
Medido nesta maquina: 3 zeros custam 1 ms, 4 custam 12 ms, 5 custam 532
ms -- cada zero a mais multiplica o preco por dezesseis. Conferir custa
0,3 microssegundo em qualquer um deles, e e essa assimetria, de mais de
quarenta mil vezes, que faz a prova de trabalho servir para alguma coisa.

Doze milissegundos, porem, nao param um ataque DISTRIBUIDO. O teto por
janela e por endereco, entao quem tem mil maquinas nunca o atinge, e cada
uma paga doze milissegundos por tentativa -- barato demais. A dificuldade
agora sobe um zero a cada bloco de falhas do mesmo endereco, com teto,
porque sem teto o navegador de um humano distraido travaria.

Quem acerta na segunda tentativa continua nao pagando nada: o desafio so
aparece depois de tres falhas.

O servidor cobra e o formulario anuncia a MESMA dificuldade. Fossem
diferentes, o navegador resolveria um problema mais facil do que o
cobrado e o login honesto passaria a falhar -- sem ninguem entender por
que. Ha teste para isso: resolver o facil e mandar onde se pede o dificil
nao passa.

Visto no navegador, com o painel no ar: o desafio chega ao formulario, o
navegador acha a resposta sozinho (13662, para o desafio dc1467e5...) e
remove o aviso antes de a tela ser fotografada. O operador nao decifra
imagem nem clica em semaforo -- ele so espera um instante que nao chega a
perceber.

295 / 321 / 187 testes verdes.
O DEFEITO: sair e digitar / voltava logado.

O Basic Auth nao tem logout. O navegador guarda a credencial e a reenvia
sozinho ate a janela fechar, e NAO existe cabecalho que mande ele
esquecer -- e uma limitacao do esquema, nao um descuido de quem o usa.
Enquanto a navegacao aceitava as duas portas, "Sair" apagava o cookie e a
visita seguinte entrava pela outra. O botao mentia.

A correcao separa por QUEM esta batendo, e nao por qual rota:

  pede text/html (navegador)  -> exige SESSAO, e so
  nao pede html (curl, cron)  -> Basic Auth, como antes

Assim o logout volta a significar alguma coisa para quem tem uma tela na
frente, e continua nao significando nada para uma ferramenta -- que e o
certo, porque ferramenta nao "sai".

Medido nos tres: navegador com Basic Auth agora recebe 302 para /login,
enquanto curl na API continua recebendo 200.

JUNTO, duas coisas que o dono pediu na mesma conversa:

- rota que nao existe responde 404 em qualquer porta, logada ou nao. Antes
  ela devolvia 302 para /login sem sessao: um convite a fazer login para
  depois descobrir que a pagina nunca existiu. "Voce precisa entrar" e
  "isso nao existe" sao respostas diferentes para perguntas diferentes.
- rota REAL sem sessao continua redirecionando para /login, em qualquer
  porta -- isso ja funcionava e segue funcionando.

E o cartao "Coerencia dos limites" sai da tela do LiteLlm: era a unica
peca que os irmaos nao tinham, e a regra da casa e que os tres mostrem a
mesma estrutura. Os achados continuam sendo apurados pelo ciclo e
contados no cartao de metrica.

295 / 321 / 187 testes verdes.
OS CARTOES

Os tres paineis mostravam conjuntos diferentes: os irmaos tinham conexoes
e combos, o LiteLlm tinha chaves virtuais e modelos, e nenhum tinha tudo.
Agora os seis existem nos tres, nesta ordem: gateway, agendador, conexoes
monitoradas, chaves virtuais, modelos cadastrados, combos de resiliencia.

A regra que decidiu os casos dificeis: o cartao EXISTE sempre; o que muda
e o conteudo. Onde o gateway nao tem o conceito, o cartao aparece com o
estado vazio dizendo por que esta vazio NAQUELE produto -- estado vazio
honesto vale mais que assimetria. Duas decisoes que valem registro:

- no 9RTKSync o catalogo vem de HTTP, e nao do banco como no irmao: o
  9Router nao persiste catalogo nenhum (tabela kv vazia, PROVIDER_MODELS
  e JS estatico), entao /v1/models e a unica fonte. A chave que autoriza
  essa leitura e a que o proprio gateway emitiu, vira cabecalho e morre
  ali -- nao e renderizada, nao vai a log, nao passa por argv;
- no LiteLlm "conexoes monitoradas" sao os destinos reais por tras dos
  modelos, agrupados por (provedor, api_base), e o status da linha e o
  PIOR veredito entre os modelos daquele destino. A alternativa -- tratar
  o proprio proxy como unica conexao -- diria sempre a mesma coisa e nao
  ajudaria a descobrir qual provedor parou.

O SSO

Botao "Configuracoes" no cabecalho, antes de "Sair", abrindo a tela com
as duas abas. OIDC com PKCE S256 sempre (o code passa pela barra de
enderecos e fica no historico; state e nonce sozinhos nao bastam), state,
nonce, e a validacao que muita implementacao esquece: o issuer devolvido
pela descoberta tem de ser IDENTICO ao configurado, que e a defesa contra
mix-up de provedor.

OIDC nao precisou de dependencia nenhuma -- urllib ja verifica TLS de
verdade na imagem (119 CAs). SAML2 precisou, e isso esta dito em vez de
disfarcado: xml.etree canonicaliza em C14N 2.0 enquanto SAML assina sobre
Exclusive C14N 1.0, nao ha verificacao RSA na stdlib, e xml.etree nao se
defende de XML Signature Wrapping -- o ataque especifico deste protocolo.

Onde cada coisa mora, por sensibilidade: o que e publico (issuer,
client_id, certificado do IdP) vai para o SQLite de preferencias; o
segredo do cliente vai para arquivo 0600, como a credencial de
recuperacao ja faz, e o ambiente vence o arquivo. Nada de cifra caseira
num banco cuja chave ficaria ao lado -- isso e teatro, e a imagem nem tem
biblioteca de criptografia.

O SSO e OPCIONAL e ha caminho de volta: sem configuracao o painel e o de
hoje, SSO_DISABLED=1 desliga pelo ambiente sem tocar no banco, o
formulario de senha nunca sai da tela, e a porta nao-HTML (Basic Auth
para curl e monitoramento) nunca passa por SSO.

383 / 390 / 283 testes verdes (eram 295 / 321 / 187).
O CANONE

Os tres sincronizadores devem ser quase clones: o que muda e cor, nome,
logo e a quem cada um se conecta. Esta rodada moveu isso para dois
arquivos e fundiu o resto.

- `identidade.py` passa a ser o UNICO lugar onde nome de produto, nome de
  gateway, paleta, icone, portas e prefixo de container podem aparecer;
- `gateway.py` passa a ser o unico lugar que sabe de que gateway se trata.
  No 9RTKSync ele absorveu `database.py` (renomeado) e o catalogo HTTP; no
  OminiRTkSync absorveu `database.py`, `discovery.py`, `providers.py`,
  `daemon.py` e `combos.py`; no LiteLlmRTKSync absorveu `client.py`,
  `engine.py` e `limits.py`.

Seis modulos deixaram de existir. Isso nao e limpeza cosmetica: enquanto
cada produto tivesse o seu proprio arquivo para a mesma ideia, cada agente
escrevia a ideia do seu jeito, e foi assim que os tres chegaram a mil
linhas de diferenca em codigo que deveria ser o mesmo.

O card "Coerencia dos limites" saiu tambem do LiteLlmRTKSync, onde tinha
sobrado como funcao orfa -- definida e nunca chamada. Era o ultimo resto
do que voce mandou tirar de todas as aplicacoes.

A CATRACA MEDIA COM UMA TRENA QUE MUDAVA DE TAMANHO

A guarda instalada na rodada passada tinha um defeito serio. O difflib
indexa a SEGUNDA sequencia, entao o mesmo par de arquivos dava numero
diferente conforme quem media: o sso.py acusava 1354 linhas medido do
OminiRTkSync e 1388 medido do LiteLlmRTKSync. Na pratica a suite do
9RTKSync passava e a do LiteLlmRTKSync reprovava com os arquivos
identicos, e eu quase afastei os tetos acreditando que o codigo tinha
piorado. Nao tinha.

Agora a medida e feita em ordem canonica -- o par sempre na direcao dada
por IRMAOS, nunca "eu contra ele". Desligar o autojunk nao resolvia:
testei, continua assimetrico (1368 contra 1312 no mesmo par).

A catraca foi provada por mutacao, e nao por ficar verde: com cinco
linhas injetadas num sso.py os tres reprovam, e ao reverter os tres
voltam a passar. A versao anterior passava com 1121 linhas de divergencia
e nunca tinha falhado uma vez -- um teste assim nao provou nada.

Divergencia de hoje, em ordem canonica: i18n 503, models 680, render
1633, sso 1354, web 1954. Total 6124 linhas, e cada numero so pode
descer.

A SUITE NAO PODE DEPENDER DE `pip install -e`

O OminiRTkSync parou de coletar com ModuleNotFoundError. A causa nao
estava no codigo: so o LiteLlmRTKSync declarava pythonpath, e o 9RTKSync
passava por acidente -- alguem tinha rodado `pip install -e` naquele repo
um dia e a instalacao editavel ficou na maquina. O irmao sem ela nem
chegava a rodar. Os tres agora declaram testpaths e pythonpath.

A DOCUMENTACAO DESCREVIA ARQUIVOS APAGADOS

Fundir modulo deixa a wiki falando de arquivo que nao existe mais, e nada
quebra: o codigo segue verde porque ninguem importa o nome velho. Quem
descobre e o leitor, procurando um arquivo que nao esta la.

`tools/valida_docs.py` ganhou a quarta verificacao -- modulo citado tem de
existir -- e passou a ser o mesmo arquivo nos tres (o do LiteLlmRTKSync
era o mais completo: usa `git ls-files` em vez de varrer o disco). Ele
acusou 20 referencias mortas no LiteLlmRTKSync e 3 no OminiRTkSync que
nenhuma suite pegava.

As citacoes [FONTE: arquivo:linha] da pagina de dimensionamento nao foram
so renomeadas: cada uma foi reapontada para onde o codigo realmente foi
parar, conferindo o texto da linha antiga contra o arquivo novo. E o
traceback de exemplo do Troubleshooting apontava para
`web/server.py`, que nao existe -- agora aponta para web.py:236.

403 / 412 / 297 testes verdes, e zero referencias mortas nos tres.
`tools/sizing.py` imprimia "CAMINHO DE API (LiteLLM)" e "CAMINHO DE
ASSINATURA (9Router/OmniRoute)". Os dois caminhos sao conceitos reais --
pagar por API contra usar assinatura -- mas nomear ali o gateway dos
irmaos faz o leitor concluir que precisa deles para dimensionar este.

O OminiRTkSync ja imprimia so "(OmniRoute)". Agora os tres falam do
proprio gateway e de mais nenhum.

Conferido rodando o script: a tabela sai igual, so o cabecalho mudou.
A stack inteira segue a regra, menos o container do Tailscale. E de
proposito: o hostname dele vira o NOME DO NO na tailnet, que e o endereco
que voce digita no navegador. Com o nome longo viraria
`http://9rtk-tailnet:9090` em vez de `http://9rtk:9090` -- mais para
digitar, sem ganho, porque dentro da stack ninguem alcanca esse container
pelo nome: ele existe para expor o painel para FORA.

Sem esse comentario, a proxima pessoa que passar os olhos no compose
"corrige" a inconsistencia e quebra o endereco que todo mundo ja decorou.

Os tres composes seguem validos (`docker compose config`).
O i18n.py estava a doze linhas de convergir, e as doze eram legitimas: o
9Router e o OmniRoute RENOVAM credencial OAuth, enquanto o LiteLLM apenas
INSPECIONA -- nao ha OAuth para renovar la. Chamar os tres de "Agendador
de renovacao" deixaria um deles mentindo na tela, e por isso a diferenca
tinha resistido a rodada inteira de convergencia.

Divergencia legitima nao se resolve escolhendo um dos textos. Se resolve
mudando de lugar: `cron.title` e `cron.result_line` sairam do catalogo e
foram para `ROTULOS_DO_PRODUTO`, em identidade.py -- que e justamente o
arquivo onde mora o que muda de produto para produto. O `translate()`
consulta o rotulo do produto antes do catalogo comum.

Resultado: os tres i18n.py agora tem o MESMO md5. Ele sai da lista de
tetos e entra em IDENTICOS, onde a comparacao e byte a byte, sem
normalizacao nenhuma. Sao oito modulos identicos agora: auth,
credential_check, i18n, logs, paginacao, prefs, protecao e sessao.

O contrato de identidade.py, que tests/test_identidade_visual.py fecha
("exatamente estes nomes, nem um a mais"), ganhou o nome novo. Foi esse
teste que reprovou a primeira tentativa -- ele estava certo: um simbolo a
mais em identidade.py e uma decisao de produto, e decisao de produto se
declara.

Divergencia total: 2045 linhas, de 6124 no inicio da rodada.
Achado por um cetico lendo o diff, nao por teste -- nenhum cobria isto.

O `models.py` ganhou nesta rodada um leitor proprio, `to_epoch_ms`, que
le carimbo ISO SEM fuso como UTC. Esta certo: e assim que os gateways
gravam, e assumir o fuso da maquina faria o mesmo dado significar horas
diferentes em dois servidores. O que ninguem percebeu e que o
`normalizer.py` continuou vivo, lendo O MESMO CAMPO com o criterio
antigo -- `datetime.fromisoformat(...).timestamp()` sem tzinfo, que o
Python interpreta como fuso local.

Medido nesta maquina (-03): "2026-09-12T15:20:22" valia 1789237222000
para o normalizer e 1789226422000 para o models. Tres horas.

Nao era diferenca academica. O normalizer e quem decide APAGAR a trava de
rate limit quando ela venceu (normalize_connection_data), e o models e
quem decide MOSTRAR "rate_limited" na tela. Com leitores que discordam, o
painel podia desenhar uma trava que o proprio sincronizador ja tinha
considerado vencida -- ou o sincronizador apagar uma que ainda valia.

O normalizer passa a usar o criterio do models. Os dois agora devolvem o
mesmo instante para ISO sem fuso, ISO com Z, ISO com deslocamento, epoch
em ms como texto, epoch em ms numerico e epoch em segundos.

E agora ha teste: `test_um_leitor_de_data_so.py` cobra que qualquer
carimbo valha o mesmo nos dois modulos. Provado por mutacao, e nao por
ficar verde -- desfazendo so a linha que fixa o fuso, ele reprova
exatamente no carimbo sem fuso; revertendo, volta a passar. No
LiteLlmRTKSync ele pula, porque aquele produto nao tem normalizer.
`RegisteredModelRecord.name` caia para `self.id`, e `id` devolve string
vazia quando o gateway nao traz nem `model_info.id` nem `id`. Nesse caso
a linha aparecia no grid com a coluna de nome VAZIA -- o operador lia uma
linha sem saber a que cadastro ela se refere.

Agora cai para travessao, que e o sinal que o resto do painel ja usa para
"nao declarado".

DUAS LICOES DESTE COMMIT, QUE VALEM MAIS QUE ELE

A primeira: o buraco foi achado por um cetico lendo o diff, e nao por
teste. Ele veio de uma rodada de convergencia que trocou o fallback
antigo por `self.id` sem repararem que `id` pode ser vazio.

A segunda: corrigi primeiro SO no LiteLlmRTKSync, e a catraca de
convergencia reprovou na hora -- models.py saltou de 7 para 18 linhas de
divergencia. Ela estava certa: o buraco era identico nos tres, entao a
correcao tambem tinha de ser. Foi exatamente para isso que a catraca foi
instalada, e e a primeira vez que ela pega um erro meu em vez de um erro
de agente.
Achado por um agente que conferiu o README do OminiRTkSync contra o
codigo, e que se recusou a copiar a afirmacao do irmao por desconfiar
dela. Estava certo.

O README do 9RTKSync dizia que o cartao de diagnostico faz
`POST /api/test-gateway` e que a troca de senha faz
`POST /api/change-password`. As duas rotas existem (web.py:400 e :402),
mas NAO e por elas que os botoes passam: o formulario do painel aponta
para `/acoes/testar-gateway` (render.py:1245), que responde com
redirecionamento -- POST-Redirect-GET, para que recarregar a pagina nao
repita a acao.

Nomear so a API fazia o leitor concluir que o botao a usava. Agora o
texto descreve as duas: os botoes pelo `/acoes/`, e as rotas `/api/`
para curl e monitoramento, que respondem JSON. As duas existem de
proposito.

E AS DUAS SECOES QUE TINHAM FICADO EM PORTUGUES

`### Configuração: .env a partir do exemplo` e `### Portas, e por que
cada uma e diferente` estavam em portugues dentro de um README em ingles,
no 9RTKSync e no LiteLlmRTKSync -- resto de uma rodada anterior. O
agente traduziu as do OminiRTkSync e avisou que os irmaos tinham as
mesmas; traduzidas agora, com o mesmo texto nos tres.

Fica de fora, de proposito: o bloco `> **Português:**` do aviso de login,
que e bilingue nos tres por decisao, e a secao `# Em português` no fim do
README do LiteLlmRTKSync, que e um resumo a mais e nao um resto -- tirar
conteudo util nao e convergir.

`tools/valida_docs.py`: 0 divergencias nos tres.
Outro achado de cetico lendo o diff. `sso.base_url_help`, que e a ajuda
do CAMPO de endereco publico, tinha adotado na rodada de convergencia um
texto que ja falava do tunel rapido trocar de endereco a cada subida --
exatamente o que `sso.tunnel_warning` diz no alerta, no MESMO modal. O
operador lia a mesma advertencia duas vezes, uma embaixo da outra.

O alerta fica com o aviso do tunel, que e o lugar dele. A ajuda volta a
falar do campo, e recupera a frase que a convergencia tinha comido: o
endereco de retorno nasce DESTE valor, e nunca dos cabecalhos da
requisicao.

Essa frase nao e enfeite: ela documenta a defesa contra um provedor de
identidade ser induzido a mandar o codigo de volta para outro lugar por
um cabecalho Host forjado. O comportamento sempre esteve correto --
`sso.py` monta o redirect_uri a partir de `config['base_url']`, e isso
nao foi tocado --, mas quem lia a tela tinha deixado de saber disso.

Nas tres linguas, e o i18n.py segue com o mesmo md5 nos tres produtos.
…caiu

Terceira regressao achada por cetico, e a pior das tres.

`web.py` le o catalogo com `try: models = [...list_models()...] except
Exception: models = []`. Com o gateway fora do ar, a excecao era engolida
e a lista vazia ia direto para a tela, que mostrava "o gateway respondeu
com o catalogo de modelos vazio". Ou seja: a afirmacao era exatamente o
contrario do que tinha acontecido, e o operador ia procurar um cadastro
faltando em vez de olhar o gateway.

Os dois irmaos nao tinham esse problema porque o render deles recebe
`estado_do_catalogo`, com ramos para `no_key` e `unreachable`. O do
LiteLlm nao recebia. As chaves de traducao ja existiam nos tres (o
i18n.py e byte a byte identico), so ninguem as usava aqui.

Agora `render_models_table` do LiteLlm recebe o mesmo parametro, e o
`web.py` marca `unreachable` quando a leitura levanta. Provado em
execucao: com "ok" a tela diz catalogo vazio, com "unreachable" diz que o
gateway nao respondeu, com "no_key" explica que falta chave ativa.

DUAS VOLTAS ATE ACERTAR, E A CATRACA DEU AS DUAS

Primeiro inventei um parametro `estado_do_catalogo` no `render_dashboard`
-- e o `models_state` ja existia ali, posto pelos agentes justamente para
que a MESMA assinatura sirva aos tres `web.py`. A catraca acusou: render
870 -> 876, web 759 -> 769. Troquei pelo nome que ja existia e o render
caiu para 862, abaixo do teto.

Sobraram seis linhas no `web.py`, e o teto subiu de 759 para 765 -- a
unica vez nesta rodada. A razao esta escrita no proprio teste, que e o
que a catraca exige: nos irmaos o estado nasce em `gateway.py` e chega
pronto em duas linhas; aqui a leitura acontece no `web.py`, entao sao
quatro. Fechar isso de vez e mover a leitura para `gateway.py`, e fica
registrado como aberto.

420 / 427 / 297 testes verdes. Divergencia total 1956 linhas, de 6124.
…sito

O pedido foi "nos footers coloque algo assim em tudo", com a imagem
"Feito com (coracao) pela Pathbit - All rights reserved (c) 2026". Um
agente ja tinha feito no painel; faltava nas telas de ANTES do login, que
sao justamente as que alguem ve sem estar autenticado -- e a de login nao
tinha rodape nenhum.

Agora sai de uma funcao so, usada pela tela de entrada, pela de login e
pelo painel. O ano vem de `datetime.now().year`: um ano escrito a mao
fica errado em primeiro de janeiro e ninguem revisa rodape. O coracao e
`bi-heart-fill` e nao o emoji, porque o cabecalho do render fixa
"Bootstrap Icons, nunca emoji" -- e emoji muda de desenho conforme o
sistema de quem olha.

A TELA QUE FICOU DE FORA, E POR QUE

A pagina de aviso NAO leva a assinatura. Ela nao e tela do produto: e o
corpo das respostas 401 e 429, servidas a quem ainda nao entrou. E a
assinatura traz "Pathbit" e o ano na mesma linha -- que sao exatamente os
dois pedacos da senha deste projeto.

Isso nao foi deducao minha: ao ligar o rodape, o teste
`test_the_401_body_never_teaches_the_credentials` reprovou na hora. Ele
proibe "pathbit", "password" e "senha" no corpo do 401, e existe porque
um dia houve ali um banner que ensinava a credencial. A saida certa nao
era afrouxar aquele teste -- era nao dar a ele nada para encontrar.

TESTE NOVO

`test_rodape_em_toda_tela.py` CONTA a assinatura em cada tela, e nao
apenas procura: quando o rodape virou funcao, a versao escrita a mao
continuou no painel por um momento e as duas apareceram juntas. "Existe"
nao bastava; "existe uma vez" basta. Ele tambem cobra que o ano seja o do
relogio e que o coracao nao seja emoji, e prova que a pagina de aviso
segue limpa.

424 / 431 / 301 testes verdes.
DOIS BURACOS NA PAGINACAO, E O PRIMEIRO ESTAVA NA TELA

`paginacao.py` chama traduzir("pagination.range") e
traduzir("pagination.label"), e NENHUMA DAS DUAS existia no catalogo. O
`translate` devolve a propria chave quando nao acha -- entao a barra que
os quatro grids ganharam nesta rodada estava desenhando o texto literal
"pagination.range" onde deveria dizer "1-10 de 47", e o <nav> anunciava
"pagination.label" para quem usa leitor de tela.

Nenhum teste pegou porque nenhum olhava o texto renderizado da barra: a
paginacao funcionava, so nao dava para ler. Agora as duas chaves existem
nas tres linguas, e a barra diz "1-10 of 47" / "1-10 de 47".

O HISTORICO DO AGENDADOR, QUE FICOU DE FORA

O pedido foi "Todos os grids, de logs tambem por favor que abre em
popups". Quatro grids ganharam paginacao -- conexoes, chaves, modelos e
combos -- e o historico do agendador, que e justamente o log que abre em
popup, continuou desenhando a lista inteira.

Aqui a paginacao e no CLIENTE, com jQuery, e nao no servidor como nos
outros quatro. A razao e o modal: virar pagina pelo servidor recarregaria
o documento, e recarregar FECHA o modal -- o operador clicaria em
"proxima" e a janela sumiria. Foi voce mesmo que apontou essa direcao ao
reclamar da querystring: "pq nao usar o jquery ou javascript?".

Provado com 23 ciclos: tres paginas, contagem dizendo 23 (o TOTAL, e nao
o tamanho da pagina), e com 6 ciclos a barra some sozinha.

Faltam ainda dois grids do pedido original: a rota /logs esta declarada
em ROTAS_CONHECIDAS sem handler nenhum -- ela nao serve nada hoje --, e o
mesmo vale para /favicon.ico. Ficam registrados.

424 / 431 / 301 testes verdes.
O `sso.py` dos tres ja trazia a federacao inteira -- `saml_disponivel`,
`url_de_ida_saml` e `processa_resposta_saml` existem em todos desde a
rodada de convergencia. O que so existia no LiteLlmRTKSync eram as
ROTAS. Dois produtos ficaram com o codigo completo e nenhuma porta para
chegar nele, e foi um agente que percebeu e se recusou a esconder:
deixou escrito no proprio codigo que nao oferecia o provedor na tela
"porque desenharia um botao para uma rota que responde 404".

Agora `/sso/saml/iniciar`, `/sso/saml/acs` e `/sso/saml/metadata` existem
nos tres, com os mesmos tres handlers portados do canone. O `metadata`
fica atras do login, como la.

DUAS GUARDAS DE SEGURANCA REPROVARAM, E ESTAVAM CERTAS

Ao ligar as rotas, `test_nada_sem_login.py` reprovou duas vezes: a ida ao
provedor e servida ANTES do require_auth, e o ACS e um POST sem sessao.
As duas coisas sao verdade e sao necessarias -- quem vai ao provedor
ainda nao tem sessao, e quem posta no ACS e o PROVEDOR, que nao carrega
cookie deste painel. O teste nao aceita isso calado: exige que a rota
publica esteja numa lista COM o motivo escrito. Os motivos foram
escritos.

O teste que afirmava a AUSENCIA do SAML foi invertido, nao apagado: ele
documentava um estado intermediario deliberado ("rota que existe e sempre
recusa e pior que rota que nao existe") e agora cobra a presenca das
tres.

TESTE NOVO: SIMETRIA DE ROTAS

`test_rotas_simetricas.py` exige que os tres sirvam EXATAMENTE o mesmo
conjunto de rotas, nos dois sentidos -- sobrar tambem e divergir. Provado
por mutacao: removendo uma rota do OminiRTkSync, a suite do 9RTKSync
reprova apontando o irmao; revertendo, volta a passar.

Ele le so onde rota e DECLARADA (o conjunto ROTAS_CONHECIDAS e as
comparacoes do despacho), e nao qualquer literal do arquivo: pegar
qualquer "/..." faria um `log("acesso a /logs negado")` virar rota.

EFEITO COLATERAL BOM: a divergencia do web.py caiu de 765 para 689
linhas. Convergir de verdade e mais barato que remendar.

O QUE AINDA NAO ESTA FEITO, E FICA DITO: a TELA do 9RTKSync e do
OminiRTkSync ainda nao OFERECE o SAML. O nucleo e as rotas existem, mas
`ler_configuracao` desses dois nao le os campos do IdP -- e a camada de
configuracao deles e justamente a parte do `sso.py` que nao convergiu.

426 / 433 / 303 testes verdes. Divergencia total 1886 linhas, de 6124.
`/favicon.ico` e `/logs` estavam em ROTAS_CONHECIDAS -- a lista que decide
entre servir e devolver 404 -- e nenhuma das duas tinha handler. Quem
pedia caia no despacho generico e recebia uma PAGINA HTML. Medido subindo
o painel numa porta efemera: 200 com `text/html` para as duas. Um
navegador pedindo o icone da aba recebia HTML.

`/favicon.ico` agora responde **204 No Content**, e e servida ANTES da
exigencia de sessao porque o navegador pede o icone tambem na tela de
login. O icone de verdade vem de um data URI embutido no HTML, de
proposito: um arquivo servido ficaria atras da autenticacao e a aba
ficaria sem icone ate o operador entrar. Mas 404 faria o navegador
registrar erro em toda visita -- 204 encerra sem corpo e sem erro.

`/logs` foi removida da lista. Nada linka para ela, nenhuma pagina da
wiki a cita, e nao havia handler: virou promessa vazia em algum refactor.
Agora responde 404, que e a verdade. O log que o dono pediu paginado e o
do agendador, que abre em popup e ja pagina de dez em dez.

Provado nos tres, por HTTP contra o servidor de verdade:
  /favicon.ico=204   /logs=404   /nao-existe=404

426 / 433 / 303 testes verdes.
…nti-robo

Substitui a prova de trabalho (PoW) baseada em loop assincrono de SHA-256
no navegador por um desafio interativo direto de selecao com 4 opcoes.

- Elimina o travamento de CPU e o spinner de espera passiva no login.
- O operador humano resolve a verificacao com 1 clique direto em 1 segundo.
- Mantem protecao contra ataques em massa: scripts HTTP sem interface sao
  rejeitados instantaneamente e robos com IA sofrem custo e latencia de visao.
- Ajusta a espera progressiva por falha para teto maximo de 5s.
- Atualiza catalogo de traducoes (i18n) e testes unitarios em simetria.
Comment thread src/nine_rtksync/sso.py
Comment thread src/nine_rtksync/sso.py
Comment thread tools/valida_docs.py
Comment thread src/nine_rtksync/render.py
Comment thread src/nine_rtksync/render.py
Comment thread src/nine_rtksync/render.py
Comment thread tests/test_cartoes_do_painel.py
Comment thread tests/test_cartoes_do_painel.py
Comment thread src/nine_rtksync/cli.py
Comment thread src/nine_rtksync/sessao.py
@elielsousa-pathbit
elielsousa-pathbit merged commit bebd15a into master Sep 14, 2026
5 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3bc2f30b84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/nine_rtksync/web.py
Comment on lines +1206 to +1207
conteudo = render_dashboard(
sso_view=self.sso_view(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the dashboard query into the renderer

When any grid contains more than ten records, clicking a generated pagination link such as ?pag_modelos=2 still renders page 1 because serve_dashboard() parses the query but never supplies it as consulta to render_dashboard(). Consequently, every table is permanently limited to its first ten entries through the actual HTTP flow; pass the parsed query through this call.

Useful? React with 👍 / 👎.

Comment on lines +86 to +96
if not _assinatura_confere(assinatura, _assina(carga)):
return None
if "|" not in carga:
return None
usuario, _, expira = carga.rpartition("|")
try:
if float(expira) < (agora if agora is not None else time.time()):
return None
except ValueError:
return None
return usuario or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate existing sessions after credential changes

When an operator changes the dashboard username or password, every previously issued browser cookie remains accepted until its eight-hour expiry because validation checks only the process-wide signing secret and timestamp, never the current credential state. This is especially problematic when a password is rotated after suspected cookie or account compromise: the old session retains full panel access. Rotate the session signing generation or bind cookies to an authentication revision whenever either credential-update endpoint succeeds.

Useful? React with 👍 / 👎.

Comment thread src/nine_rtksync/web.py
Comment on lines +548 to +550
if com_desafio and protecao.precisa_de_desafio(endereco):
desafio = protecao.novo_desafio()
dificuldade = protecao.dificuldade_para(endereco)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create challenges with the computed difficulty

After repeated failures increase dificuldade_para() to five or six, this code still calls novo_desafio() without that value, so the stored challenge always contains the default four options. Passing the later-computed number to the renderer does not add options, and resposta_confere() ignores its dificuldade argument, meaning the advertised escalation never takes effect. Compute the difficulty first and pass it into novo_desafio(quantidade=...).

Useful? React with 👍 / 👎.

Comment on lines +137 to +142
with _trava:
_desafios[desafio_id] = {
"criado_em": time.time(),
"alvo": alvo,
"opcoes": [item[0] for item in escolhidos],
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound and expire challenges during challenge creation

Once an address has accumulated three failures, every unauthenticated GET /login creates another entry in _desafios, but novo_desafio() neither invokes _limpa() nor enforces a size limit. Since login-page GETs are not rate-limited and cleanup currently runs only through registra_tentativa() on a subsequent POST, a client can continuously request the login page and grow this process-wide dictionary without bound. Expire old entries and impose a hard cap while creating challenges.

Useful? React with 👍 / 👎.

Comment on lines +157 to +159
- TS_SERVE_CONFIG=/config/serve.json
volumes:
- 9rtk_tailnet:/var/lib/tailscale

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mount the Tailscale serve configuration

When the new tailnet profile is enabled, the container is instructed to load /config/serve.json, but its only mounted path is /var/lib/tailscale; a repo-wide search also finds no serve.json or generation step. Thus the sidecar cannot load the configuration intended to publish the panel and gateway, so the documented profile either fails startup or joins the tailnet without forwarding those services. Add the serve configuration and mount it at the declared path.

Useful? React with 👍 / 👎.

Comment thread src/nine_rtksync/web.py
Comment on lines +1081 to +1085
def handle_credentials(self, campos: Dict[str, List[str]]) -> None:
"""Troca usuário e senha do painel, com a política de força inteira."""
lang = self.resolve_language()
user = (campos.get("user", [""])[0] or "").strip()
password = (campos.get("password", [""])[0] or "").strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require the current password before replacing credentials

For browser requests, authentication now succeeds from the eight-hour session cookie, but this handler accepts only the replacement username and password and never re-verifies the current password. A stolen session can therefore replace the dashboard credentials and retain persistent access, even though the SSO settings path explicitly requires the local password to prevent the same escalation. Require the current dashboard or recovery password before calling update_auth_credentials() in both credential-update paths.

Useful? React with 👍 / 👎.

Comment on lines +123 to +126
command: >-
tunnel --no-autoupdate
${TUNNEL_TOKEN:+run --token ${TUNNEL_TOKEN}}
${TUNNEL_TOKEN:---url http://9rtk-router:20128}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce gateway authentication before starting the public tunnel

Enabling the new tunel profile with an empty TUNNEL_TOKEN publishes 9rtk-router:20128 through a public quick tunnel, while the same compose file still hardcodes both REQUIRE_LOGIN=false and REQUIRE_API_KEY=false. The documentation tells operators to change those values in .env, but these literal compose entries do not interpolate the environment, so that instruction cannot secure the deployed gateway and anyone with the tunnel URL can access its inference and administrative endpoints. Make the flags environment-driven and refuse to start this profile unless both protections are enabled.

Useful? React with 👍 / 👎.

Comment on lines +172 to +175
inflation = f"{grand_lines / grand_turns:.2f}x" if grand_turns else "n/a"
print(f"inflacao de contar linha em vez de id : {inflation}")
print(f"T_in entrada que conta p/ ITPM mediana : {statistics.median(billable_input):.0f}")
print(f"T_in entrada que conta p/ ITPM p90 : {percentile(billable_input, 0.90):.0f}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle histories with no eligible sessions

On a machine without Claude history, or when --since/--until excludes every session or all sessions fall below the minimum thresholds, billable_input and the other metric lists remain empty and the first statistics.median() call raises StatisticsError. This makes the new measurement tool unusable for a common first-run or empty-window case; detect zero analyzed sessions and return a clear no-data result before calculating medians and ratios.

Useful? React with 👍 / 👎.

Comment thread src/nine_rtksync/web.py
Comment on lines +811 to +813
config = self.configuracao_sso()
if not config.esta_ligado() or config.provedor != "saml":
self.recusa_sso()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject inactive SAML routes without dereferencing a dict

Whenever OIDC is enabled, rota_existe() admits every /sso/ path, so an unauthenticated request to /sso/saml/iniciar or /sso/saml/acs reaches these handlers. In this panel configuracao_sso() returns the dictionary produced by configuracao_efetiva(), but both handlers call config.esta_ligado() and access config.provedor, causing an AttributeError and a dropped request instead of the intended generic refusal or 404. Check the dictionary provider safely or keep the unimplemented SAML routes out of dispatch.

Useful? React with 👍 / 👎.

Comment thread src/nine_rtksync/web.py
Comment on lines +941 to +946
base_dir = self.sso_base_dir()
novo_segredo = campo("client_secret")
if novo_segredo and not sso.segredo_vem_do_ambiente():
if not sso.grava_segredo(base_dir, novo_segredo):
self.redirect_to_dashboard("danger", translate("sso.secret_failed", lang))
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate SSO settings before overwriting the active secret

When an operator supplies a replacement client secret together with an invalid issuer, base URL, or empty allowlist, this code writes the secret file before problemas_da_configuracao() rejects the request. The previous non-secret configuration remains active but now uses the rejected replacement secret, so a save reported as failed can immediately break an otherwise working OIDC login; the same partial update occurs if saving the SQLite configuration subsequently fails. Validate against the prospective secret first and commit the secret only after the rest of the configuration can be saved.

Useful? React with 👍 / 👎.

@elielsousa-pathbit
elielsousa-pathbit deleted the feat/identidade-e-padronizacao branch September 14, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant