You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
O captcha original gerava hash = wp_hash( $answer . 'ffc_math_salt' ). O hash derivava só da resposta, com salt fixo, sem vínculo de sessão, expiração ou uso único. Um par (ffc_captcha_ans, ffc_captcha_hash) capturado uma vez valia para sempre, em qualquer formulário. O espaço de respostas era 0–45.
Estado: código todo entregue. PR1 (#1054), PR2 (#1055), PR3 (#1065) e PR4 (#1066), mais sete correções intercaladas (#1057, #1059, #1062, #1064, #1067, #1068, #1069). Os três modos foram smoke-testados e produziram dez achados, todos corrigidos. Falta apenas traduzir as strings novas (workflow do Loco, fora do código) e reconferir no ambiente de testes. A PR5 foi cortada — ver "Flip do padrão: parado, não planejado".
Decisões travadas
Tema
Decisão
Biblioteca
ALTCHA, implementação nativa em PHP; lib MIT oficial (altcha-org/altcha) como especificação de referência
Motivo vs. Cap
Cap não tem servidor PHP de primeira parte; os portes comunitários trazem aviso explícito de não-monitoramento de segurança, e o plugin WP do Cap exige instância Cap externa
Widget
Vendorizado em libs/js/, no padrão do thumbmarkjs. Sem CDN
Chave HMAC
Derivada de wp_salt( 'nonce' ) — sem option nova, sem entrada em uninstall.php
Anti-replay
Transient ffc_*, consumo one-time no verify
Modos
A ALTCHA + fallback math · B só math · C só ALTCHA
Recomendado
C — o público do download CSV são organizadores, em desktop com JS
Padrão
B no upgrade e na instalação nova
Endpoint
challenge="<url>" apontando para AJAX próprio — imune a cache de página
UI
Aba nova ?page=ffc-settings&tab=captcha. Sem capability nova
Versão
6.23.0 minor, com Security + Added
humanInteractionSignature
Desligado, decisão registrada no código e na doc
setCookie
Mantido desligado
i18n
Strings do plugin via $altcha.i18n + wp_localize_script, não o bundle de 52 KB
Argon2 / Scrypt
Fora de escopo — exigem workers vendorizados à parte
Modo A é acessibilidade, não segurança. Como o servidor aceita qualquer uma das duas provas, o atacante escolhe a mais barata — a força efetiva do A é a do math. Fica no código como compatibilidade e rollback.
Nenhum servidor próprio. Emissão do desafio = admin-ajax.php do próprio plugin; proof-of-work = navegador do visitante; verificação = PHP do WordPress; tráfego externo = nenhum.
Conferir ≠ gastar (aprendizado do #1061, vale para o ALTCHA)
O uso único do token, introduzido na PR1, quebrou o único fluxo de duas requisições do plugin: o download público de CSV valida o mesmo captcha na tela de detalhes e no download. A tela 1 queimava o token e a tela 2 recusava a resposta que o visitante acabara de ver aceita.
A correção separou as duas operações, e isso é agora parte do contrato, não da implementação matemática:
CaptchaProviderInterface::peek() — confere sem resgatar. Toda estratégia que vale a pena é de uso único: uma solução de proof-of-work é replayável até o servidor registrá-la, exatamente como o token matemático. Implementá-lo como um verify() que não consome é errado — um token já gasto precisa ser recusado no peek também, senão a contradição só anda um passo adiante.
Regra: o captcha é consumido pela ação que ele autoriza, não pela leitura de metadados que a antecede.
PRs
✅ PR1 — Security: fechar o replay do captcha matemático — #1054 (mergeado)
Core\Captcha\ChallengeStore — emissão stateless, consumo one-time no verify
Hash math vinculado a expiração + consumo único
Reescrever tests/Unit/UtilsTest.php e tests/Unit/FrontendShortcodesTest.php
Teste de regressão: mesmo payload duas vezes → segunda rejeitada
Entrada em [Unreleased] / Security
Core\Captcha\ChallengeSigner — assinatura com chave derivada, separada do ledger (SRP)
Nonce por desafio — sem ele, dois visitantes com a mesma resposta no mesmo segundo compartilhariam um token
SecurityService::with_fresh_challenge() no catch único do pipeline e em 13 branches das quatro superfícies AJAX
Bloco ===== CAPTCHA DEBUG ===== removido do SecurityFieldsGuard
⚠ Regressão que escapou: o uso único quebrou o fluxo de duas requisições do CSV público. Corrigido no #1061. A revisão da PR1 mediu 13 branches de rejeição que precisavam de desafio novo e não perguntou quais superfícies validam o mesmo token mais de uma vez — nenhum teste cobria isso, porque cada handler é testado com o outro mockado.
Auditoria pós-merge — dois itens ficaram de fora, ambos fechados no #1064:
DynamicFragments::handle() não passava pelo contrato — chamava SecurityService::generate_simple_captcha() direto, em dois pontos. Era o quinto site de refresh; a PR2 cobriu os quatro de retry e não viu este, deixando o docblock de challenge_payload() afirmando um consumidor que ele não tinha.
AltchaCaptcha::peek() — confere sem consumir e recusa token já gasto
Endpoint ffc_altcha_challenge (priv + nopriv, sem nonce por construção), com throttle próprio por endereço
Core\Captcha\CompositeCaptcha (modo A), math dentro de <noscript>
assets/js/ffc-captcha.js — reset em toda rejeição, via um fio só (ffc:request-rejected, emitido pelo FFC.request)
Ramo altcha no applyChallenge() — nomeado explicitamente, porque "nada a fazer" e "provider desconhecido" são respostas diferentes
Mensagem legível quando o widget falha, com o caso de contexto inseguro dito à parte por não ser transitório
Testes PHP resolvem o próprio desafio — sem browser; testes JS mockam o widget
Quatro decisões do épico foram contraditas pelo bundle 3.2.2, todas verificadas no código do widget:
Plano
Realidade
Vendorizar o build .min.js
É o ESM, que exige wp_enqueue_script_module() — WP 6.5+, e o plugin declara 6.4. Vendorizado o UMD
hideLogo / hideFooter / humanInteractionSignature como opções do widget
O elemento aceita nove atributos; os demais vão como JSON em configuration e, escritos como atributo, são ignorados em silêncio
Guardrail sobre o produto cost × counter
maxnumbernão existe no 3.x. A dificuldade é o tamanho do número secreto e nada mais
Bloquear o modo C sem HTTPS por prudência
Não é prudência: o widget lançaSecure context (HTTPS) required. O modo A é a única opção viável em HTTP puro
Confirmados: challenge="<url>" está correto, e o widget devolve o payload clássico v1 — logo o verificador PHP em v1 é o certo.
Três achados nos próprios testes: a AjaxWiringTest pegou um órfão real e revelou dois pontos cegos seus (retrocesso para fora do prefixo nopriv_, e chamada por constante de classe não reconhecida); quatro classes de teste passavam por acidente de ordem de arquivos, dependendo de get_locale nunca ter sido definida; e a suíte JS media cargas em vez de comportamento, por empilhar listeners.
Guardrail com piso e teto sobre o fator de trabalho — e aplicado na leitura também, não só no save
Plumbing das chaves — declaradas em get_default_settings(), sanitizadas por tab, lidas por um leitor tipado
Log do uso de fallback com IP hasheado e truncado, com rótulo traduzido no Activity Log
reference-security.php — dizia "basic math challenge"
CLAUDE.md — justificativa do PublicCsvExporter emendada, mais uma seção nova sobre a arquitetura de captcha
Três desvios do plano, todos deliberados:
algorithm não foi exposto. Só SHA-256 vem no bundle; oferecer SHA-384/512 exigiria vendorizar workers à parte, o que o épico já colocou fora de escopo. Um seletor de um item só é ruído.
O bloqueio do modo C usa add_settings_error(), não WP_Error. É o idioma que todo o resto do SettingsSaveHandler usa para retorno de salvamento; um WP_Error precisaria de plumbing próprio para chegar à tela e não chegaria mais claro.
O leitor tipado é CaptchaSettings, não acessores em SettingsReader. As chaves de captcha têm limites, e limites precisam morar em algum lugar de qualquer forma; espalhar SettingsReader::get_int() deixaria o clamp do lado de fora, onde cada leitor teria de repeti-lo. É a forma que o Declarative settings registry: declare each ffc_settings key once #993 defende, construída onde já se pagava por ela.
✅ Smoke do modo C, parte 1 — widget invisível fora dos formulários — #1067 (mergeado)
Primeiro achado do smoke: o widget aparecia nos certificados e não aparecia no CSV público nem nos agendamentos.
O wp_enqueue_script estava dentro de um if ( $has_form || $has_verification ) no Loader — a página de CSV não casa nenhum dos dois, então o <altcha-widget> era renderizado sem o script que o define, e um custom element sem definição é uma caixa vazia
Corrigido com registrar no Loader, enfileirar no render (AltchaCaptcha::render_fields()) — shortcodes rodam em the_content, antes do wp_footer, e os dois scripts são de rodapé
✅ Smoke do modo C, parte 2 — seis achados — #1068 (mergeado)
Ordem de carregamento do idioma. O bundle carrega antes do glue, e é ele quem cria a store $altcha.i18n — então cada widget já tinha resolvido o idioma quando as strings do plugin eram registradas. registerStrings() passou a chamar reapplyLanguage(), que invoca o configure({ language }) publicado por cada elemento. ⚠ Esta correção era necessária mas não era a causa do sintoma — ver "As strings novas não estão traduzidas" abaixo
display (bar/floating) sumia com o widget. No CSS do próprio bundle, bar é position: fixed; bottom: -100% e floating é display: none; left/top: -100%. São modos fora da tela por construção, para fluxos ancorados a um botão de submit. Opção removida
theme não mudava nada. O bundle não tem uma regra [theme=, :host(, prefers-color-scheme ou color-scheme sequer — o atributo existe e não é lido. Opção removida
Um teste novo (test_layout_and_theme_are_not_offered_as_attributes) impede que os dois voltem, e um caso de save prova que um POST antigo com as chaves removidas não as ressuscita
Tema de verdade, no lugar do dial falso: variáveis --altcha-* mapeadas nos tokens --ffc-* em ffc-frontend.css. ⚠ Correção de uma afirmação minha: não são "as nove variáveis que o bundle lê" — o bundle expõe ~45; nove foram mapeadas, as que têm token equivalente no plugin. As demais ficam no padrão
Widget centralizado, opções do ALTCHA em caixa dedicada escondida no modo math-only, ícone da aba trocado por 🤖
Rebuild do ffc-captcha.min.js — o fix de idioma foi commitado sem regerar o bundle e o gate "Verify minified assets" pegou. Sem isso a correção teria ido para produção sem efeito nenhum
Modo B (só math) passou limpo. Modo A produziu dois achados, e a documentação da aba nova entrou junto.
O modo A se contradizia sem JavaScript. O aviso "This form requires JavaScript to verify that you are not a robot" pertence ao modo C, onde é verdade. No composto era renderizado do mesmo jeito — em vermelho, logo acima do campo matemático que o visitante deveria responder. O aviso virou parâmetro do render (AltchaCaptcha::render_widget( bool )), porque é propriedade do modo, não do widget
O painel vazio do widget também some sem JavaScript, pela mesma razão: o custom element nunca é definido, então é uma caixa azul sem conteúdo. Uma folha de estilo não sabe dizer "scripting está desligado", então a regra viaja dentro do mesmo <noscript> que carrega a metade matemática
Caixa do widget estreita demais. O widget nasce com 320px e --altcha-max-width é aplicado como width no wrapper interno; nessa largura o logotipo, que o rodapé empurra para a direita com justify-content: flex-end, não tem para onde ir e cola no texto. Agora ocupa a largura do painel
Revisão da documentação (page=ffc-settings&tab=documentation) — a aba Captcha não tinha página. Entrou config-captcha.php: os três modos lado a lado com o que cada um exige e custa, as opções do widget, e o que o proof-of-work envia e não envia. Duas menções desatualizadas corrigidas: a página de verificação dizia "math captcha" especificamente, e a de cache não dizia que nos modos ALTCHA não há o que atualizar
3 testes novos em CompositeCaptchaTest, verificados falhando contra o código anterior
⚠ As strings novas não estão traduzidas — e essa é a causa real do widget em inglês
O .po/.potnão contêm nenhuma das strings deste épico — nem as do widget (I am not a robot, Verifying…, Protected by ALTCHA), nem as da aba, nem o aviso do <noscript>. Todas passam por __() corretamente; o catálogo é que não tem as entradas, então __() devolve o inglês.
Consequência para o diagnóstico do #1068: a reapplyLanguage() corrigiu uma dependência de ordem real (o widget resolve o idioma antes de o glue registrar as strings, e não voltava a olhar), mas o sintoma que o smoke viu teria continuado igual, porque o que era registrado na store já era inglês. A saída de console bundle: object dizia exatamente isso e eu li como confirmação do caminho de entrega, não como um catálogo vazio.
Não é trabalho de código. O catálogo é mantido pelo Loco Translate (x-generator: Loco no cabeçalho do .po) e não há nenhuma ferramenta de i18n no package.json, no composer.json ou nos workflows — sincronizar o .pot e traduzir é o fluxo do wp-admin. Vale para todas as strings novas do épico, não só as do widget.
Flip do padrão: parado, não planejado (era a PR5)
A PR5 previa forçar o modo C como padrão numa release posterior, com banner ⚠. Cortada, e não por preguiça: a premissa era que C é estritamente melhor, e não é — ele exige HTTPS e JavaScript. Trocar o padrão mudaria o comportamento justamente das instalações que nunca abriram a aba, que são as menos propensas a perceber que um formulário parou de aceitar envios.
O que a PR4 entregou torna o flip desnecessário: a aba expõe a escolha com a consequência de cada modo dita ao lado dela, o save recusa C onde ele não funciona, e o aviso empurra para C exatamente onde ele é possível. O operador recebe uma escolha informada em vez de uma mudança silenciosa de comportamento — mesmo raciocínio que estacionou o flip de IP do cliente no #902.
Revisitar só com gatilho: evidência de que o aviso não está sendo lido (instalações em HTTPS seguindo em math depois de várias releases), ou uma decisão de exigir JavaScript no plugin como um todo. Não por especificação.
O smoke no ambiente de testes foi o instrumento mais produtivo deste épico: encontrou treze defeitos que a suíte não pegava, três deles pré-existentes e sem relação com o captcha.
O padrão comum aos três primeiros: cada lado está correto isoladamente e quebra na costura — entre duas requisições, entre duas camadas — e a suíte não vê porque cada lado é testado com o outro mockado. A AjaxWiringTest já cobre "os dois lados existem e concordam no nome"; nenhum guarda cobre "os dois lados concordam sobre quem consome o token" nem "o tipo que sai do $wpdb cabe no parâmetro que o recebe" (este último é o #1060).
O padrão comum aos achados dos modos ALTCHA é outro: falha silenciosa de integração com um componente de terceiros. O idioma errado, o atributo ignorado, o widget fora da tela e o custom element sem definição não emitem erro algum — nem no console, nem no PHP. Nenhum teste unitário os alcança, porque todos mockam o widget; só um navegador de verdade vê. Foi por isso que quatro decisões do épico precisaram ser corrigidas contra o código do bundle, nunca contra a documentação.
E um terceiro, específico do modo A: os dois achados sem JavaScript existiam porque o modo A nunca foi olhado sem JavaScript. A metade <noscript> é a razão de o modo existir e a única que nenhum teste alcança — jsdom sempre tem "JavaScript".
Pendências
Aberto de propósito, com o motivo:
Item
Estado
Traduzir as strings novas
Único item do épico realmente pendente. Fluxo do Loco Translate no wp-admin: sincronizar o .pot e traduzir. Vale para todo o épico, não só o widget
Adiado com medição: 16 timers inventariados, zero vazamento observável
Flip do padrão
Cortado — ver acima
Nada mais ficou em aberto. Os cinco sites que emitem desafio passam pelo contrato, os seis que verificam também, e não há chamada a generate_simple_captcha() fora do provider.
Contexto
O captcha original gerava
hash = wp_hash( $answer . 'ffc_math_salt' ). O hash derivava só da resposta, com salt fixo, sem vínculo de sessão, expiração ou uso único. Um par(ffc_captcha_ans, ffc_captcha_hash)capturado uma vez valia para sempre, em qualquer formulário. O espaço de respostas era 0–45.Decisões travadas
altcha-org/altcha) como especificação de referêncialibs/js/, no padrão dothumbmarkjs. Sem CDNwp_salt( 'nonce' )— sem option nova, sem entrada emuninstall.phpffc_*, consumo one-time no verifychallenge="<url>"apontando para AJAX próprio — imune a cache de página?page=ffc-settings&tab=captcha. Sem capability novaSecurity+AddedhumanInteractionSignaturesetCookie$altcha.i18n+wp_localize_script, não o bundle de 52 KBModo A é acessibilidade, não segurança. Como o servidor aceita qualquer uma das duas provas, o atacante escolhe a mais barata — a força efetiva do A é a do math. Fica no código como compatibilidade e rollback.
Nenhum servidor próprio. Emissão do desafio =
admin-ajax.phpdo próprio plugin; proof-of-work = navegador do visitante; verificação = PHP do WordPress; tráfego externo = nenhum.Conferir ≠ gastar (aprendizado do #1061, vale para o ALTCHA)
O uso único do token, introduzido na PR1, quebrou o único fluxo de duas requisições do plugin: o download público de CSV valida o mesmo captcha na tela de detalhes e no download. A tela 1 queimava o token e a tela 2 recusava a resposta que o visitante acabara de ver aceita.
A correção separou as duas operações, e isso é agora parte do contrato, não da implementação matemática:
CaptchaProviderInterface::peek()— confere sem resgatar. Toda estratégia que vale a pena é de uso único: uma solução de proof-of-work é replayável até o servidor registrá-la, exatamente como o token matemático. Implementá-lo como umverify()que não consome é errado — um token já gasto precisa ser recusado nopeektambém, senão a contradição só anda um passo adiante.PRs
✅ PR1 —
Security: fechar o replay do captcha matemático — #1054 (mergeado)Core\Captcha\ChallengeStore— emissão stateless, consumo one-time no verifytests/Unit/UtilsTest.phpetests/Unit/FrontendShortcodesTest.php[Unreleased] / SecurityCore\Captcha\ChallengeSigner— assinatura com chave derivada, separada do ledger (SRP)SecurityService::with_fresh_challenge()nocatchúnico do pipeline e em 13 branches das quatro superfícies AJAX===== CAPTCHA DEBUG =====removido doSecurityFieldsGuard⚠ Regressão que escapou: o uso único quebrou o fluxo de duas requisições do CSV público. Corrigido no #1061. A revisão da PR1 mediu 13 branches de rejeição que precisavam de desafio novo e não perguntou quais superfícies validam o mesmo token mais de uma vez — nenhum teste cobria isso, porque cada handler é testado com o outro mockado.
✅ PR2 — contrato e unificação — #1055 (mergeado)
CaptchaProviderInterface+MathCaptcha+Core\Captcha\CaptchaProvider::resolve()(fallback silencioso para math em valor desconhecido)SecurityService::validate_security_fields()delega; os 6 sites de verificação não mudaram — hoje são 5 que consomem + 1 que confere (ajax_info(), desde o Download público de CSV: captcha aceito na tela 1 e recusado na tela 2 (regressão do #1054) #1061)templates/security-fields.php+templates/captcha/math-fields.php.ffc-security-containerna cadeia de seletores doassets/js/ffc-dynamic-fragments.jsCaptchaProviderTestcsv-download.test.js(cb5eb53)@covers SecurityServicenoCaptchaProviderTest(entregue no fix(captcha): scope the booking refresh to its form and make the ids unique #1057)Auditoria pós-merge — dois itens ficaram de fora, ambos fechados no #1064:
DynamicFragments::handle()não passava pelo contrato — chamavaSecurityService::generate_simple_captcha()direto, em dois pontos. Era o quinto site de refresh; a PR2 cobriu os quatro de retry e não viu este, deixando o docblock dechallenge_payload()afirmando um consumidor que ele não tinha.Shortcodes::get_new_captcha_data()não tinha chamador — removido direto: diferente do precedente doget_audit_log_summary()(Deprecation cycle:get_audit_log_summary()legacy keys +UserDataRestControllerfacade #730), não erapublic staticalcançável por FQCN.✅ Intercalado — #1056: ids duplicados e refresh não escopado — #1057 (mergeado)
refreshCaptchaescopado ao formulário, casando porname✅ Intercalado — #1058: agendamento commitado reportado como falha — #1059 (mergeado)
Encontrados pelo smoke manual, pré-existentes e sem relação com o captcha:
TypeErrorfatal pós-commit —$appointment['id']chega do$wpdbcomo string eAppointmentReceiptHandler::get_receipt_url()declaraintsobstrict_typescatchdo handler passou a\Throwablee o bloco pós-commit foi isolado por etapaon_appointment_created()passava o id do agendamento no 4º argumento, que é$user_idappointment_createdno log✅ Intercalado — #1061: CSV público recusava o captcha que acabara de aceitar — #1062 (mergeado)
ChallengeStore::is_spent()— metade só-leitura do ledgerSecurityService::peek_simple_captcha()/peek_security_fields()CaptchaProviderInterface::peek()+MathCaptcha::peek()— no contrato, não na implementaçãoajax_info()confere;authorize_start()ehandle_request()consomem✅ Concluída a auditoria da PR2 — #1064 (mergeado)
DynamicFragments::handle()encaminhachallenge_payload()verbatim e não nomeia mais os camposprovidere ignora o que não reconhece, em vez de aplicar meio payloadsecurityBlocks()também coleta markup em cache anterior ao invólucro.ffc-security-containerShortcodes::get_new_captcha_data()removidoCaptchaProvidermockado✅ PR3 — ALTCHA — #1065 (mergeado)
FFC_ALTCHA_VERSION— bytes idênticos ao upstream,sha256confere com o manifesto do jsDelivrCore\Captcha\AltchaCaptcha— challenge, HMAC,expiresAt, verificação comhash_equals+ consumo one-timeAltchaCaptcha::peek()— confere sem consumir e recusa token já gastoffc_altcha_challenge(priv + nopriv, sem nonce por construção), com throttle próprio por endereçoCore\Captcha\CompositeCaptcha(modo A), math dentro de<noscript>assets/js/ffc-captcha.js— reset em toda rejeição, via um fio só (ffc:request-rejected, emitido peloFFC.request)altchanoapplyChallenge()— nomeado explicitamente, porque "nada a fazer" e "provider desconhecido" são respostas diferentesQuatro decisões do épico foram contraditas pelo bundle 3.2.2, todas verificadas no código do widget:
.min.jswp_enqueue_script_module()— WP 6.5+, e o plugin declara 6.4. Vendorizado o UMDhideLogo/hideFooter/humanInteractionSignaturecomo opções do widgetconfiguratione, escritos como atributo, são ignorados em silênciocost × countermaxnumbernão existe no 3.x. A dificuldade é o tamanho do número secreto e nada maisSecure context (HTTPS) required. O modo A é a única opção viável em HTTP puroConfirmados:
challenge="<url>"está correto, e o widget devolve o payload clássico v1 — logo o verificador PHP em v1 é o certo.Três achados nos próprios testes: a
AjaxWiringTestpegou um órfão real e revelou dois pontos cegos seus (retrocesso para fora do prefixonopriv_, e chamada por constante de classe não reconhecida); quatro classes de teste passavam por acidente de ordem de arquivos, dependendo deget_localenunca ter sido definida; e a suíte JS media cargas em vez de comportamento, por empilhar listeners.✅ PR4 — UI e configuração — #1066 (mergeado)
TabCaptcha+includes/settings/views/ffc-tab-captcha.php, em Segurança, sem capability novatype,auto,hideLogo,hideFooter, fator de trabalho e validade do desafio —displayethemetambém foram expostos aqui e removidos no fix(captcha): drop two dials that could not work, and fix the widget language (#1053) #1068, por serem dials que o bundle não honraget_default_settings(), sanitizadas por tab, lidas por um leitor tipadoreference-security.php— dizia "basic math challenge"CLAUDE.md— justificativa doPublicCsvExporteremendada, mais uma seção nova sobre a arquitetura de captchaTrês desvios do plano, todos deliberados:
algorithmnão foi exposto. Só SHA-256 vem no bundle; oferecer SHA-384/512 exigiria vendorizar workers à parte, o que o épico já colocou fora de escopo. Um seletor de um item só é ruído.add_settings_error(), nãoWP_Error. É o idioma que todo o resto doSettingsSaveHandlerusa para retorno de salvamento; umWP_Errorprecisaria de plumbing próprio para chegar à tela e não chegaria mais claro.CaptchaSettings, não acessores emSettingsReader. As chaves de captcha têm limites, e limites precisam morar em algum lugar de qualquer forma; espalharSettingsReader::get_int()deixaria o clamp do lado de fora, onde cada leitor teria de repeti-lo. É a forma que o Declarative settings registry: declare eachffc_settingskey once #993 defende, construída onde já se pagava por ela.✅ Smoke do modo C, parte 1 — widget invisível fora dos formulários — #1067 (mergeado)
Primeiro achado do smoke: o widget aparecia nos certificados e não aparecia no CSV público nem nos agendamentos.
wp_enqueue_scriptestava dentro de umif ( $has_form || $has_verification )noLoader— a página de CSV não casa nenhum dos dois, então o<altcha-widget>era renderizado sem o script que o define, e um custom element sem definição é uma caixa vaziaLoader, enfileirar no render (AltchaCaptcha::render_fields()) — shortcodes rodam emthe_content, antes dowp_footer, e os dois scripts são de rodapéinit*()que dava early-return na página errada): enfileirar por tipo de página é uma aposta sobre onde o shortcode aparece✅ Smoke do modo C, parte 2 — seis achados — #1068 (mergeado)
$altcha.i18n— então cada widget já tinha resolvido o idioma quando as strings do plugin eram registradas.registerStrings()passou a chamarreapplyLanguage(), que invoca oconfigure({ language })publicado por cada elemento. ⚠ Esta correção era necessária mas não era a causa do sintoma — ver "As strings novas não estão traduzidas" abaixodisplay(bar/floating) sumia com o widget. No CSS do próprio bundle,baréposition: fixed; bottom: -100%efloatingédisplay: none; left/top: -100%. São modos fora da tela por construção, para fluxos ancorados a um botão de submit. Opção removidathemenão mudava nada. O bundle não tem uma regra[theme=,:host(,prefers-color-schemeoucolor-schemesequer — o atributo existe e não é lido. Opção removidatest_layout_and_theme_are_not_offered_as_attributes) impede que os dois voltem, e um caso de save prova que um POST antigo com as chaves removidas não as ressuscita--altcha-*mapeadas nos tokens--ffc-*emffc-frontend.css. ⚠ Correção de uma afirmação minha: não são "as nove variáveis que o bundle lê" — o bundle expõe ~45; nove foram mapeadas, as que têm token equivalente no plugin. As demais ficam no padrãoffc-captcha.min.js— o fix de idioma foi commitado sem regerar o bundle e o gate "Verify minified assets" pegou. Sem isso a correção teria ido para produção sem efeito nenhum🔍 Smoke dos modos A e B — #1069
Modo B (só math) passou limpo. Modo A produziu dois achados, e a documentação da aba nova entrou junto.
AltchaCaptcha::render_widget( bool )), porque é propriedade do modo, não do widget<noscript>que carrega a metade matemática--altcha-max-widthé aplicado comowidthno wrapper interno; nessa largura o logotipo, que o rodapé empurra para a direita comjustify-content: flex-end, não tem para onde ir e cola no texto. Agora ocupa a largura do painelpage=ffc-settings&tab=documentation) — a aba Captcha não tinha página. Entrouconfig-captcha.php: os três modos lado a lado com o que cada um exige e custa, as opções do widget, e o que o proof-of-work envia e não envia. Duas menções desatualizadas corrigidas: a página de verificação dizia "math captcha" especificamente, e a de cache não dizia que nos modos ALTCHA não há o que atualizarCompositeCaptchaTest, verificados falhando contra o código anterior⚠ As strings novas não estão traduzidas — e essa é a causa real do widget em inglês
O
.po/.potnão contêm nenhuma das strings deste épico — nem as do widget (I am not a robot,Verifying…,Protected by ALTCHA), nem as da aba, nem o aviso do<noscript>. Todas passam por__()corretamente; o catálogo é que não tem as entradas, então__()devolve o inglês.Consequência para o diagnóstico do #1068: a
reapplyLanguage()corrigiu uma dependência de ordem real (o widget resolve o idioma antes de o glue registrar as strings, e não voltava a olhar), mas o sintoma que o smoke viu teria continuado igual, porque o que era registrado na store já era inglês. A saída de consolebundle: objectdizia exatamente isso e eu li como confirmação do caminho de entrega, não como um catálogo vazio.Não é trabalho de código. O catálogo é mantido pelo Loco Translate (
x-generator: Locono cabeçalho do.po) e não há nenhuma ferramenta de i18n nopackage.json, nocomposer.jsonou nos workflows — sincronizar o.pote traduzir é o fluxo do wp-admin. Vale para todas as strings novas do épico, não só as do widget.Flip do padrão: parado, não planejado (era a PR5)
A PR5 previa forçar o modo C como padrão numa release posterior, com banner ⚠. Cortada, e não por preguiça: a premissa era que C é estritamente melhor, e não é — ele exige HTTPS e JavaScript. Trocar o padrão mudaria o comportamento justamente das instalações que nunca abriram a aba, que são as menos propensas a perceber que um formulário parou de aceitar envios.
O que a PR4 entregou torna o flip desnecessário: a aba expõe a escolha com a consequência de cada modo dita ao lado dela, o save recusa C onde ele não funciona, e o aviso empurra para C exatamente onde ele é possível. O operador recebe uma escolha informada em vez de uma mudança silenciosa de comportamento — mesmo raciocínio que estacionou o flip de IP do cliente no #902.
Revisitar só com gatilho: evidência de que o aviso não está sendo lido (instalações em HTTPS seguindo em math depois de várias releases), ou uma decisão de exigir JavaScript no plugin como um todo. Não por especificação.
Próximos passos, nesta ordem
$wpdbsem shape, mais o guarda de baseline.Critérios de conclusão
ModuleBoundaryTestverde sem regenerar baselineAjaxWiringTestverde com o endpoint novo — e o guarda ficou mais forte no caminhoDynamicFragmentsservindo o desafio do provider configurado (refactor(captcha): serve the cached-page refresh from the provider (#1053) #1064)Smoke manual — treze defeitos encontrados
O smoke no ambiente de testes foi o instrumento mais produtivo deste épico: encontrou treze defeitos que a suíte não pegava, três deles pré-existentes e sem relação com o captcha.
TypeErrorpós-commit)display=bar/floatingfaz o widget sumir (fora da tela pelo bundle)themenão faz nada (o bundle não lê o atributo)O padrão comum aos três primeiros: cada lado está correto isoladamente e quebra na costura — entre duas requisições, entre duas camadas — e a suíte não vê porque cada lado é testado com o outro mockado. A
AjaxWiringTestjá cobre "os dois lados existem e concordam no nome"; nenhum guarda cobre "os dois lados concordam sobre quem consome o token" nem "o tipo que sai do$wpdbcabe no parâmetro que o recebe" (este último é o #1060).O padrão comum aos achados dos modos ALTCHA é outro: falha silenciosa de integração com um componente de terceiros. O idioma errado, o atributo ignorado, o widget fora da tela e o custom element sem definição não emitem erro algum — nem no console, nem no PHP. Nenhum teste unitário os alcança, porque todos mockam o widget; só um navegador de verdade vê. Foi por isso que quatro decisões do épico precisaram ser corrigidas contra o código do bundle, nunca contra a documentação.
E um terceiro, específico do modo A: os dois achados sem JavaScript existiam porque o modo A nunca foi olhado sem JavaScript. A metade
<noscript>é a razão de o modo existir e a única que nenhum teste alcança — jsdom sempre tem "JavaScript".Pendências
Aberto de propósito, com o motivo:
.pote traduzir. Vale para todo o épico, não só o widgetNada mais ficou em aberto. Os cinco sites que emitem desafio passam pelo contrato, os seis que verificam também, e não há chamada a
generate_simple_captcha()fora do provider.