Skip to content

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

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

  • 434 testes passando (617 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.

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

Nao saber ler e um estado diferente de saber que esta ruim. Agora a
sonda reconhece o prefixo, nao sai da maquina e responde "unsupported"
com o motivo por extenso, sem tocar no estado que o gateway gravou.
Duas pontas do mesmo principio, achadas ao consertar o vermelho falso.

1. A leitura preenchia test_status vazio com "active". Como o fim do ciclo
   grava de volta o que leu, o sincronizador afirmava ao gateway uma saude
   que nunca mediu -- uma chave ilegivel aparecia verde na tela. Coluna
   vazia significa "ninguem testou", e agora e isso que ela continua
   dizendo.

2. O compose roda o sincronizador como uid 1000 para dividir o volume do
   gateway sem estragar as permissoes dele. Um volume nomeado montado
   sobre um diretorio ausente na imagem nasce root, e o processo perdia a
   escrita do proprio log ("Permission denied: /app/logs"). O diretorio
   passa a existir na imagem com o dono certo, e o volume o herda.

Medido depois da correcao: as cinco conexoes ficam com test_status NULL e
credentialState "unsupported" -- nem verde nem vermelho, que e a verdade
sobre uma credencial que o gateway guarda cifrada e nao conseguimos abrir.
…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 para
manter assim. Quem escapava era o Makefile: "-p 9092:9090" publica em
TODA interface -- o Wi-Fi do cafe, a VLAN do escritorio.

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.

Junto, o alvo do workflow de limpeza de pacotes apontava para
"ominirtksyncatest", nome que nao casa com imagem nenhuma.

269 testes verdes.
A linha do argumento --password anunciava "(padrao: pathbit)" em arquivo
versionado -- e um cetico do enxame a encontrou procurando outra coisa.
Duas falhas na mesma linha:

- e credencial de fabrica publicada, que vira a senha real de toda
  instalacao que copiou e colou;
- o valor era falso. O padrao real e string vazia (config.py:56); o
  painel gera uma credencial de recuperacao no primeiro boot e registra
  em qual arquivo ela esta, nunca o valor.

O repositorio ja garantia que a TELA nao mostra "admin / pathbit"
(test_web_security.py:203). O --help ficou fora dessa guarda e continuou
imprimindo por tempo indeterminado. A guarda nova varre todo texto de
ajuda em src/ atras de qualquer credencial de fabrica conhecida, e
confere que o padrao no codigo continua vazio -- porque se alguem
reintroduzir um valor, o texto de ajuda volta a mentir.
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.
Duas coisas que so aparecem quando alguem olha a tela e a resposta HTTP.

1. "egress.title" chegava CRU ao operador, cinco vezes no mesmo painel --
   uma por conexao, dentro do modal de detalhe. translate() devolve a
   propria chave quando nao acha o texto, entao nada quebrou e nenhum
   teste falhou: so o rotulo "Saida de rede" virou "egress.title" na
   cara de quem usa. As outras quatro chaves egress.* ja estavam la nos
   tres idiomas; faltava justamente a do titulo do grupo.

2. O cabecalho Server anunciava "BaseHTTP/0.6 Python/3.14.7" na primeira
   linha de toda resposta, inclusive no 401 que sai antes de qualquer
   autenticacao -- logo acima da CSP, do X-Frame-Options e do nosniff que
   o resto do cabecalho instala. A mesma resposta que fecha as portas
   dizia qual e a fechadura. O LiteLlmRTKSync ja calava esse valor; os
   irmaos nao, e nenhum dos tres tinha teste sobre isso.

As duas guardas cobrem o caminho que deixou os defeitos passarem: uma
reprova chave usada e nao declarada (provada contra o proprio defeito,
que ela aponta com arquivo e linha), a outra reprova handler que anuncia
a pilha. A varredura de chave ignora de proposito as montadas em tempo de
execucao, como translate(f"health.{status}") -- inclui-las acusaria como
orfas nove chaves que existem e sao usadas, e guarda que mente perde a
autoridade de reprovar.

281 testes verdes.
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.
Duas coisas que so aparecem quando se le a wiki ao lado do codigo, e
nenhuma suite pegava.

O `Dashboard.md` do LiteLlmRTKSync ainda listava "Limit findings" como um
elemento da tela. O cartao saiu do painel -- voce mandou tirar de todas as
aplicacoes, e ele era justamente o setimo cartao que so um dos tres tinha.
A verificacao continua existindo, mas pela linha de comando (`--status`),
e a pagina agora diz isso em vez de prometer um cartao que nao esta la.

E a pagina de SSO do OminiRTkSync se chamava `SSO.md` enquanto a dos dois
irmaos se chama `Single-Sign-On.md`. Quem navega entre os tres repositorios
segue o mesmo caminho e cai num 404 em um deles. Renomeada, com a barra
lateral alinhada.
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.
DECISAO INFERIDA, NAO PEDIDA -- vale dizer isso antes de tudo.

O README estava pela metade em duas linguas: a secao "Documentation" em
ingles (foi copiada dos irmaos numa rodada anterior) e todo o resto em
portugues -- "Recursos Principais", "Como Executar via Docker", "Testes
Unitarios". Meio a meio e pior do que qualquer uma das duas escolhas.

Adotei o ingles, por tres razoes que valem mais que a minha preferencia:
os READMEs do 9RTKSync e do LiteLlmRTKSync estao em ingles, a wiki DESTE
repositorio (docs/wiki/*.md) esta em ingles, e o repositorio e publico.
Se voce preferir o contrario -- os tres em portugues -- e uma troca de
idioma, nao de conteudo, e da para reverter sem perder nada.

O QUE NAO MUDOU

Os valores continuam sendo os deste projeto, e isso foi conferido um a
um contra docker-compose.example.yml, pyproject.toml, identidade.py e
config.py: gateway OmniRoute, prefixo `ominirtk-`, portas 8082 e 9092,
comandos `ominirtksync` / `OminiRTKSync` / `omini-rtksync`. Copiar valor
do irmao e o erro mais facil de cometer aqui, e ja aconteceu antes neste
projeto.

As mencoes a 9Router e LiteLLM que sobraram sao as duas legitimas: o
ponteiro para o projeto irmao no topo, e a tabela que mostra as portas
dos tres lado a lado para explicar por que cada uma difere -- ela existe
igual no README do 9RTKSync, e e o que deixa claro que da para rodar os
tres ao mesmo tempo.

`tools/valida_docs.py`: 0 divergencias.
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.
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/omini_rtksync/sso.py
Comment thread src/omini_rtksync/gateway.py
Comment thread src/omini_rtksync/gateway.py
Comment thread src/omini_rtksync/gateway.py
Comment thread src/omini_rtksync/sso.py
Comment thread tools/valida_docs.py
Comment thread src/omini_rtksync/render.py
Comment thread src/omini_rtksync/render.py
Comment thread src/omini_rtksync/render.py
Comment thread tests/test_sso_oidc.py
@elielsousa-pathbit
elielsousa-pathbit merged commit 23aeb4f 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: 789426675f

ℹ️ 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/omini_rtksync/web.py
Comment on lines 1287 to +1288
lang=self.resolve_language(),
sso_config=visao.get("config") or {},

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 request query to the dashboard renderer

When any grid has more than ten rows, following a generated link such as ?pag_modelos=2 still displays the first ten because this call never passes query as consulta; render_dashboard therefore uses its None default for every table. The URL and pagination controls change, but later pages remain inaccessible through the actual web route.

Useful? React with 👍 / 👎.

Comment on lines +184 to +187
# Publica o painel e o gateway na tailnet, cada um na sua porta.
- TS_SERVE_CONFIG=/config/serve.json
volumes:
- ominirtk_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.

P1 Badge Mount the Tailscale Serve configuration

Starting the documented tailnet profile sets TS_SERVE_CONFIG to /config/serve.json, but the service only mounts /var/lib/tailscale; a repo-wide search also finds no serve.json to mount. Consequently the sidecar has no Serve mapping to ominirtk-router:20128, so joining the tailnet does not make the gateway reachable as this profile promises.

Useful? React with 👍 / 👎.

Comment thread src/omini_rtksync/web.py
Comment on lines +1123 to +1127
if self.settings and self.settings.update_auth_credentials(user, password):
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/credenciais-atualizadas")
self.send_header("Content-Length", "0")
self.end_headers()

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 Invalidate existing sessions after changing credentials

After a successful password or username change, previously issued session cookies remain valid because session verification only checks the process-wide signing secret and expiry, and this path neither rotates that secret nor records a credential/session generation. Thus a cookie captured before the change continues authorizing dashboard access for up to eight hours despite the operator rotating credentials; the JSON password-change path has the same issue.

Useful? React with 👍 / 👎.

Comment thread src/omini_rtksync/web.py
Comment on lines +627 to +633
def freio_do_sso(self) -> bool:
"""Aplica o teto por endereço. Devolve True quando já respondeu 429."""
endereco = protecao.endereco_do_cliente(self.client_address)
pode, espere = protecao.registra_tentativa(endereco)
if not pode:
self.responde_429(espere)
return True

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 Avoid rate-limiting every proxied client as one address

When the dashboard is reached through a tunnel or reverse proxy, client_address is the proxy's socket address, so every user shares this single rate-limit bucket. Any unauthenticated client can make eleven login or SSO requests and force all legitimate users behind that proxy to receive 429 responses for the remainder of the five-minute window; client identity needs to account for explicitly trusted proxy forwarding rather than always using the peer address.

Useful? React with 👍 / 👎.

Comment thread src/omini_rtksync/web.py
Comment on lines +959 to +963
base_dir = self.sso_base_dir()
novo_segredo = (campos.get("oidc_client_secret", [""])[0] or "").strip()
if novo_segredo and not sso.segredo_vem_do_ambiente():
if not sso.grava_client_secret(base_dir, novo_segredo):
self.redirect_to_dashboard("danger", translate("sso.save_failed", lang))

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 replacing the client secret

When an operator enters a replacement secret together with incomplete OIDC fields or an empty allowlist, this block overwrites the working secret before the later validation rejects the request. The old public configuration remains stored but immediately starts using the new, mismatched secret, so a save reported as failed can still break every subsequent SSO login; stage the secret and persist it only after all validation and configuration writes succeed.

Useful? React with 👍 / 👎.

Comment on lines +206 to +208
```bash
docker logs 9rtk-tunel 2>&1 | grep -o 'https://[a-z0-9-]*\.trycloudflare\.com'
```

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 Use the correct container name in the tunnel log command

Following this command after starting the OminiRTkSync tunel profile fails because the compose service explicitly names the container ominirtk-tunel, while the documentation asks Docker for logs from 9rtk-tunel. Users of the quick-tunnel flow therefore cannot retrieve the generated public URL with the documented command.

Useful? React with 👍 / 👎.

Comment thread src/omini_rtksync/web.py
Comment on lines +153 to +157
if ":" not in decodificado:
return False
user, password = decodificado.split(":", 1)
# Delega ao Settings: credenciais salvas, padrão de fábrica e a
# credencial de recuperação são avaliadas lá, num lugar só.

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 Apply brute-force protection to Basic authentication

An attacker can bypass every newly added login delay, challenge, and per-address attempt limit by sending guesses as Basic credentials to a non-HTML endpoint such as /api/status. This branch verifies the same dashboard credentials without calling any protecao limiter, so the form protection does not constrain automated password guessing through the still-supported API authentication path.

Useful? React with 👍 / 👎.

# nasciam com dono root e o omniroute -- que roda como `node` (1000) --
# perdia a escrita no proprio volume, recusando o login com
# "EACCES: permission denied, mkdir '/app/data/db/backups'".
user: "1000:1000"

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 Keep the host credential mount traversable by UID 1000

The compose service now runs as UID 1000 while HOST_HOME remains mounted below /root; in the Alpine-based image /root is not traversable by an unprivileged user. As a result the discovery engine silently sees none of the host's Gemini, Claude, GitHub, or Codex credential files, defeating the synchronizer's primary discovery behavior for users of this compose file; mount the home under a traversable path such as /host or adjust directory ownership and permissions.

Useful? React with 👍 / 👎.

Comment thread src/omini_rtksync/web.py
Comment on lines +1123 to +1127
if self.settings and self.settings.update_auth_credentials(user, password):
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/credenciais-atualizadas")
self.send_header("Content-Length", "0")
self.end_headers()

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 credential before changing it

The credential-change action only requires an existing session and validates the strength of the replacement; it never asks for or verifies the current local password. Anyone who obtains an eight-hour session cookie can therefore replace the dashboard username and password and turn temporary session theft into persistent access, even though the SSO settings path explicitly reauthenticates for the same threat model.

Useful? React with 👍 / 👎.

Comment thread src/omini_rtksync/web.py
Comment on lines +833 to +835
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 SAML routes cleanly while OIDC is active

With OIDC enabled, rota_existe admits every /sso/ path, so an unauthenticated request to /sso/saml/iniciar or /sso/saml/acs reaches this code. configuracao_sso() returns a dictionary, however, and calling config.esta_ligado() raises an uncaught AttributeError, causing the server to close the request and log a traceback instead of returning the intended generic refusal or 404.

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