Skip to content

Release v6.0.0 — correção de contrato provada contra a API real - #46

Merged
andrenfe merged 21 commits into
masterfrom
release/v6.0.0
Sep 3, 2026
Merged

Release v6.0.0 — correção de contrato provada contra a API real#46
andrenfe merged 21 commits into
masterfrom
release/v6.0.0

Conversation

@andrenfe

@andrenfe andrenfe commented Sep 3, 2026

Copy link
Copy Markdown
Member

O que é

Major de correção de contrato. Nenhuma funcionalidade nova: são bugs provados por sonda
ao vivo contra a API real entre 01 e 03/09, a maioria em métodos que nunca puderam
funcionar
.

Em nenhum deles a especificação era a culpada — o SDK é que estava errado. Evidência
versionada em tests/fixtures/live-contracts/, com dado sensível redigido.

⚠️ Breaking changes

Medidas comparando o dist/index.d.ts desta branch com o construído a partir da v5.2.0
publicada — não estimadas.

superfície antes agora
serviceInvoices.downloadPdf/downloadXml invoiceId?: string invoiceId: string
inboundProductInvoices.getXml/getPdf/getEventXml Promise<string> Promise<InboundFileResource>
transportationInvoices.downloadXml/downloadEventXml Promise<string> Promise<InboundFileResource>
consumerInvoices.downloadPdf/Xml/RejectionXml Buffer / NfeFileResource ConsumerInvoiceFileResource
consumerInvoices.getItems/getEvents environment? options?, retorno próprio
consumerInvoices.cancel devolvia a nota ConsumerInvoiceCancellationResponse
companies.getCertificateStatus tipo inline CertificateStatusSummary
PACKAGE_NAME '@nfe-io/sdk' 'nfe-io'
wiring de credencial 403 na chamada ConfigurationError na hora

Zero métodos removidos. Só uma quebra interrompe compilação de código que funcionava:
o invoiceId obrigatório. As outras oito são em superfícies que já estavam quebradas —
métodos que só respondiam 404, retornos que vinham undefined, tipos que mentiam sobre o
que continham.

Roteiro completo em MIGRATION.md.

Os bugs, por ordem de gravidade

Nove recursos fiscais usavam a credencial errada. O cliente de api.nfse.io resolvia a
chave de dados num host fiscal, que responde 403. Só funcionavam por acidente, via
o fallback dataApiKey → apiKey. Quem configurava dataApiKey — o que a documentação
recomenda — tomava 403.

companies.getCertificateStatus() lia uma forma que a API nunca devolveu. Retornava
{hasCertificate: undefined} para toda empresa, e derrubava em cascata outros três métodos.
O mesmo bug está no client-php e no client-ruby, por cópia — há change aberta nos dois.

healthCheck() respondia error sempre. Enviava pageCount: 1, que a API recusa com
400 "pageCount must be between 1 and 50" — o limite inferior do servidor está um a mais do
que a própria mensagem diz. O método existe para dizer se a integração está de pé.

Downloads devolviam objeto tipado como texto. As rotas de entrada respondem
{ publicTemporaryUri }; binário nunca trafegou nelas.

O erro da API não chegava ao chamador. extractErrorMessage lia dois dos quatro
envelopes que a plataforma usa. Nos outros dois o chamador recebia HTTP 400 error — o
status que já tinha. Foi assim que The File field is required. ficou invisível enquanto o
upload de certificado não funcionava.

Toda requisição mentia sobre a versão. O User-Agent saía @nfe-io/sdk@3.0.0 — pacote
inexistente, versão três majors atrás. 93.995 requisições em 30 dias, e nenhuma
informação de versão chegando à plataforma.

O que ficou melhor além das correções

  • O portão de publicação passou a poder reprovar. publish.yml tinha
    continue-on-error: true no passo de testes, com justificativa escrita que era falsa. Os
    três bugs corrigidos em 01/09 saíram por esse portão.
  • A suíte de integração voltou a rodar. Nada carregava o .env, então ela pulava
    sempre. Execução local foi de 742 para 813 testes; quatro assertions apodrecidas
    apareceram e foram corrigidas.
  • validate:spec detecta drift entre cópias da mesma seção — 30 dos 131 endpoints são
    declarados em mais de uma spec e as cópias divergiram.
  • A tag da release e o package.json precisam concordar antes de publicar.
  • Duas guardas novas: versão fixada em literal e documentação citando método inexistente
    passam a quebrar a suíte. Ambas verificadas por mutação.

Correções de registro

Dois métodos que o diagnóstico anterior dava como quebrados não estavam — a amostra é
que era a exceção:

  • productInvoiceQuery.downloadPdf devolve 200 e %PDF-1.4 com chave real. O 406
    medido antes vinha de chave inexistente.
  • legalPeople/naturalPeople (14 métodos) respondem 200. O 400 vinha de empresa com
    id de 32 caracteres; a rota aceita só ObjectId de 24 hex — limite do servidor.

Upstream

Nove issues abertas em nfe/docs a partir desta rodada — nfe/docs#335, #336, #337,
#338, #345, #346, #347, #348, #349 — mais um comentário em nfe/docs#343.
A #349 cobre as páginas publicadas do SDK Node e está atribuída a @andrenfe; as outras
seguem sem dono.

Verificação

typecheck   limpo
lint        0 erros (32 warnings pre-existentes de `any`)
test:types  18/18, no type errors
build       ok, dist reporta nfe-io@6.0.0
suite CI    776 passed | 54 skipped
suite local 813 passed | 7 skipped
portão      tag v6.0.0 passa; v5.2.0 reprova

Depois do merge

  1. Criar a release v6.0.0 no GitHub — é o que dispara o publish.yml.
  2. O portão confere tag × package.json, roda testes/lint/typecheck/test:types/build,
    verifica os artefatos e publica com provenance.
  3. nfeio-docs precisa de PR próprio — rastreado em nfe/docs#349: a página pública
    docs/desenvolvedores/bibliotecas/nodejs/multi-host-routing.md tem a mesma tabela errada
    de credencial que este PR corrige aqui, e mais quatro classes de divergência.
  4. Apagar este arquivo (.github/PULL_REQUEST_v6.0.0.md).

Probes de contrato contra a API viva carregam ids de empresa, formato da
conta e sequencia de chamadas. O repositorio e publico e o campo files do
package.json governa apenas o pacote npm, nao o que fica visivel no GitHub.

A regra entra antes de qualquer arquivo existir em scripts/probes/: o
gitignore so age sobre o que o git ainda nao rastreia.
Envelope real (status, content-type, Location, forma do corpo) com corpo
sintetico: nenhum CNPJ, chave de acesso, id de empresa ou URL pre-assinada
real entra no repositorio publico. A evidencia crua fica no vault.

Consumidos pelos mocks de fix-consumer-invoices-contract e
fix-binary-downloads-inbound, que dependiam de decisao de contrato que so
a API viva responde.
… certificado

Dois bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01).
Em nenhum dos dois havia divergencia de spec: o SDK e que estava errado.

1) As duas chaves da plataforma sao complementares, nao intercambiaveis — cada
   uma responde 403 nos hosts da outra familia. O cliente HTTP de api.nfse.io
   resolvia a chave de DADOS num host FISCAL, afetando nove recursos:
   productInvoices, productInvoicesRtc, transportationInvoices,
   inboundProductInvoices, municipalTaxes, certificates, stateTaxes,
   taxCalculation e o lado v2 de companies.

   Funcionavam por acidente: quem configurava so apiKey caia no fallback
   dataApiKey -> apiKey. Quem configurava dataApiKey tomava 403.

   getCteHttpClient e getNfseMainHttpClient apontavam para o MESMO host com
   chaves diferentes — duas regras contraditorias. Fundidos em getNfseHttpClient.

   BREAKING: cliente configurado SO com dataApiKey deixa de acessar os nove
   recursos fiscais e lanca ConfigurationError no acesso, em vez de falhar com
   403 na chamada.

2) uploadCertificate enviava o campo multipart 'certificate'; a API faz binding
   de 'file' e respondia 400 {"errors":{"file":["The File field is required."]}}.
   O metodo nunca tinha como completar.

A suite existente afirmava o comportamento errado como correto e so verificava
que o acesso nao lancava, nunca qual chave ia para o fio — por isso o bug
sobreviveu. O teste novo afirma o header por familia de host.
As rotas de entrada (/inbound/{chave}/xml, /pdf e /events/{evento}/xml,
compartilhadas por CT-e e NF-e Distribuicao) respondem com um objeto
{ publicTemporaryUri } — URL pre-assinada e temporaria. Binario NUNCA trafega
nessas rotas, e o header Accept nao altera a resposta.

Provado por sonda ao vivo em 2026-09-01 sobre chaves reais de entrada. Isso
FALSIFICA a premissa da review de 07/2026, que supunha PDF binario chegando como
string e corrompendo bytes: nao ha bytes. O plano original (trocar para
http.getBuffer) estava errado.

Os cinco metodos passam de Promise<string> para Promise<InboundFileResource>.
Nenhum chamador correto quebra — o retorno anterior ja era este objeto se
passando por string.

O envelope difere do usado por NFC-e e NF-e produto, que nomeiam o campo 'uri'.
Sao dois envelopes na mesma plataforma, entao InboundFileResource e um tipo
separado de NfeFileResource, de proposito.

Testes existentes mockavam string e passavam mesmo assim, porque so verificavam
passagem adiante. Realinhados ao formato real, mais tres testes de contrato:
xml e pdf devolvem o mesmo formato, nao se envia Accept, e o retorno nao e
Buffer nem string.
…NFC-e

Contrato conferido nas duas specs (repo e nfeio-docs, identicas nestes paths) e
provado por sonda ao vivo em 2026-09-01.

- cancel() aceita reason (query da spec) e devolve
  ConsumerInvoiceCancellationResponse em vez da nota
- getItems()/getEvents() aceitam paginacao cursor (limit/startingAfter) e
  devolvem envelopes proprios com hasMore; o de eventos deixa de reusar o tipo
  do recurso de produto, que tem outra forma
- downloadPdf() aceita force; os tres downloads passam a devolver
  ConsumerInvoiceFileResource ({ uri }) em vez de Buffer — a API devolve JSON
  com URL e IGNORA o header Accept
- retrieve()/getItems()/getEvents() param de enviar environment, que a spec nao
  define nessas rotas (a API tolera, mas e ruido)

list() CONTINUA exigindo environment: a sonda mostrou 400 'environment has to be
production or test' sem ele. A tarefa 2.3 previa tornar o parametro opcional 'na
direcao permissiva' — isso teria introduzido um bug. A spec e que esta errada.

Novos aliases derivados da spec em types.ts, mais teste de alinhamento
tests/types/consumer-invoice-alignment.test-d.ts pinando: uri vs
publicTemporaryUri sao envelopes distintos, items/events carregam hasMore, e o
cancelamento devolve o recurso de cancelamento.
30 dos 131 endpoints sao declarados em mais de uma spec (companies,
certificates, statetaxes, webhooks) e as copias divergem. O SOURCES.json nao
pegava: ele compara sha256 repo x docs, e este drift e *entre* specs do mesmo
lado.

O SOURCES.json ganha `sharedSections` com a fonte canonica de cada grupo, e o
validate:spec compara as copias contra ela campo a campo.

A granularidade foi escolhida por medicao, nao por gosto. Comparar a operacao
inteira -- mesmo descontando prosa -- marca 100% dos paths dos grupos A e B
como divergentes: ruido de forma (`content: {}` x ausente, `$ref` x inline,
ordem de chave). Campo a campo (`caminho -> tipo/enum`), o grupo C isola
exatamente as 24 contradicoes reais de contentType/status, sem um falso
positivo.

Quatro classes: type-mismatch e enum-mismatch falham o build, enum-subset
avisa (a copia esta atrasada), presenca de campo e informativa.

As 116 divergencias de hoje entram como baseline declarada, agrupadas por
CAUSA e nao por campo, cada uma com motivo e pendencia upstream:

  74 enum-mismatch  grupo A  valores de enum camelCased ('sP' no lugar de
                             'SP', 'active' no lugar de 'Active') em
                             nf-produto-v2 e nf-consumidor-v2 -- defeito de
                             serializacao, achado nesta medicao
  24 type-mismatch  grupo C  contentType/status como integer nas copias; o
                             fio manda string (sonda 2026-09-01)
  18 enum-subset    grupo B  nf-servico-v1 atrasada em taxRegime,
                             legalNature e certificate.status

Baseline que deixa de reproduzir e reportada como obsoleta, para nao virar
tapete. O que falha o build e drift novo.

Canonica de webhooks e nf-consumidor-v2, nao nf-produto-v2: e a unica com o
TIPO certo (string) e a unica superset (11 campos x 10, so ela declara `id`).

discoverSpecs() do validador passa a aceitar .json -- contribuintes-v2.json,
canonica das secoes de companies, nunca tinha sido validado, enquanto o
generate-types.ts ja o processava.

Sem efeito em runtime nem na API publica: dist/index.d.ts sai byte-identico ao
build anterior, e src/generated/ nao muda uma linha que nao seja timestamp.

Testes: 17 novos, com fixture para cada classe -- inclusive a de diferenca
puramente de forma, que e o caso que invalidou as tres primeiras estrategias.
publish.yml marcava o passo de testes com `continue-on-error: true`, e um bloco
logo abaixo justificava por escrito:

    "Some tests failed, but continuing with publish"
    "This is expected for integration tests without API credentials"

A justificativa e falsa. Medido: sem credencial a suite da 41 passed | 4 skipped,
exit 0. Os testes de integracao PULAM, nao falham -- o guard
shouldRunIntegrationTests() cuida disso desde sempre. O continue-on-error
protegia contra um modo de falha que nao existe e, em troca, deixava passar todos
os que existem. Os tres bugs de contrato corrigidos nesta mesma versao sairam por
esse portao.

Agora publish.yml roda testes, lint, typecheck e test:types antes do build, e
qualquer um deles barra a publicacao. test:types tambem entrou no ci.yml: eram 18
assertions -- incluindo os guards de alinhamento de contrato escritos em 01/09 --
que nunca executavam. Custam 3,3s.

A suite de integracao tambem nao podia ser rodada: dotenv era devDependency e
nada carregava o .env, entao NFE_API_KEY chegava vazia e a integracao pulava
sempre, inclusive na maquina de quem tem credencial. Carregado em tests/setup.ts
(dotenv nao sobrescreve ambiente existente, entao export manual e CI continuam
vencendo o arquivo).

Com isso a execucao local foi de 742 para 779 testes -- 37 que nunca haviam
rodado -- e tres assertions apodrecidas apareceram: afirmavam
Array.isArray(companies) contra um ListResponse ({data, page}), e uma quarta lia
companies.length (undefined). Sao de antes da migracao para ListResponse.
Corrigidas. Os outros 3 arquivos de integracao passaram intactos.

O guard NAO mudou: em CI a integracao continua pulando sem
RUN_INTEGRATION_TESTS=true. Credencial de conta compartilhada nao vai para
runner. Verificado nos dois modos.

O it.skip do upload de certificado fica desligado, agora com motivo honesto no
lugar do vago: exigiria .pfx versionado (material sensivel) e CRIA empresa numa
conta compartilhada. O que ele protegeria ja esta coberto sem rede pelo unitario
que afirma o campo multipart.

Registrado tambem, em generation.test.ts, o efeito que este portao NAO cobre: a
suite roda a geracao de verdade e suja src/generated/ com ~22 linhas de carimbo.
A correcao pertence ao pipeline de geracao.

Nada em src/. Zero efeito em runtime ou API publica.
`healthCheck()` respondia `status: 'error'` SEMPRE -- com credencial valida e
API no ar -- porque enviava `pageCount: 1`:

    GET /v1/companies?pageCount=1  -> 400 "pageCount must be between 1 and 50"
    GET /v1/companies?pageCount=2  -> 200
    GET /v1/companies              -> 200

O limite inferior do servidor esta um a mais do que a propria mensagem diz.
Medido em 2026-09-02 contra api.nfe.io com chave real; o metodo agora omite o
parametro, em vez de carregar um numero magico contornando defeito alheio.
O off-by-one vai para o time de API como pendencia propria.

O teste afirma o parametro efetivamente enviado. Afirmar so `status: 'ok'`
contra um mock nao pegaria a regressao: o mock responde 200 para qualquer
query, inclusive a que a API recusa. Nao havia nenhum teste de healthCheck
ate aqui.
`getCertificateStatus` lia `{hasCertificate, expiresOn, isValid}`. A API
devolve outra coisa:

    GET /v1/companies/{id}/certificate
    -> { "certificates": [ { providerType, resolution, taxPayerId, thumbprint,
                             taxId, subject, validUntil, modifiedOn, status } ] }

Nenhum dos tres campos lidos existe, entao o metodo devolvia
`{hasCertificate: undefined}` e o `if` que calcula os derivados nunca era
verdadeiro. Isso derrubava em cascata `checkCertificateExpiration`,
`getCompaniesWithCertificates` e `getCompaniesWithExpiringCertificates` --
quatro metodos publicos. Confirmado na spec (CertificatesMetadataResource,
contribuintes-v2) e no fio em 2026-09-02.

Empresa sem certificado responde 200 com `certificates: []`, nao 404 -- 9 de
12 empresas sondadas na conta estao nesse caso.

O resumo mantem `expiresOn` em vez de renomear para `validUntil`: e o mesmo
nome que a API usa quando o certificado vem embutido no item da listagem
(CompanyCertificateV1), entao manter alinha as duas superficies em vez de
quebrar o chamador de novo. Os itens crus ficam expostos em `certificates`.

A varredura por conta deixa de fazer N+1
--------------------------------------
`getCompaniesWithCertificates` e `getCompaniesWithExpiringCertificates`
chamavam `getCertificateStatus` uma vez por empresa, em serie, sobre
`listAll()`. Enquanto o metodo estava quebrado isso era invisivel; consertado,
a conta do time (>=500 empresas) faria >=500 requisicoes sequenciais por
chamada.

Cheguei com um pool de concorrencia pronto e a sonda tornou a discussao
desnecessaria: `GET /v1/companies` ja devolve `certificate` em TODO item, com
`{thumbprint, modifiedOn, expiresOn, status}`. As duas varreduras passam a ler
dai. Zero requisicao extra, zero maquina de concorrencia. Medido ao vivo: as
duas varreduras juntas, sobre a conta inteira, em 9,8s.

Os mocks
--------
Os testes unitarios alimentavam a forma inventada e por isso passavam: o mock
validava a leitura contra a propria invencao. Reescritos contra o envelope
real, mais os casos que faltavam (lista vazia, escolha entre varios
certificados, status != Active, `validUntil` ausente).

`tests/unit/companies.test.ts` tambem stubava `getDaysUntilExpiration` em 365
dias fixos no mock de modulo -- todo teste de vencimento media a constante do
mock, nao a data. O mock agora e parcial: so o que depende de um .pfx de
verdade fica falso; a aritmetica de datas e real.

E `tests/integration/certificates.integration.test.ts` afirma o contrato
contra a API real, inclusive que os campos antigos NAO existem na resposta.
Um mock nao consegue afirmar o nome de um campo que so a API sabe.
…lote

`downloadPdf(companyId)` e `downloadXml(companyId)` sem o id montavam
`/serviceinvoices/pdf` e `/serviceinvoices/xml`. O servidor casa a rota `/{id}`
e le o sufixo como identificador:

    GET /v1/companies/{id}/serviceinvoices/pdf
    -> 404 "service invoice with id (pdf) was not found"

A rota em lote nao existe: nao esta na spec nf-servico-v1 nem no nfeio-docs.
O comentario `// Bulk download for company (returns ZIP)` descrevia uma rota
inventada. Medido em 2026-09-02.

BREAKING CHANGE: `invoiceId` passa a ser obrigatorio nos dois metodos. Toda
chamada afetada ja falhava em runtime; agora falha na compilacao do consumidor.
Para varias notas, itere sobre os ids. Nota em MIGRATION.md.

Documentacao
------------
README, docs/downloads.md e docs/API.md prometiam "ZIP com todas as notas",
com exemplo pronto para copiar -- em quatro lugares, incluindo um bullet de
features. Removidos, com a medicao no lugar.

Testes
------
Havia DUAS copias dos testes de download (tests/unit/service-invoices.test.ts
e tests/unit/core/resources/service-invoices.test.ts), as duas afirmando o
caminho em lote. Elas afirmavam o caminho que o SDK montava, nunca que a API
o servisse -- passavam verdes contra uma rota inexistente.

Ressalva: `npm run typecheck` NAO teria pego isso. O tsconfig limita o include
a `src/**/*` e ainda exclui `**/*.test.ts`, entao nenhum arquivo de teste e
verificado. As chamadas sobreviventes so apareceram quando a suite rodou, e uma
montou `/serviceinvoices/undefined/pdf`. Lacuna inventariada.
Duas correcoes que sairam da mesma medicao.

1) Accept dos downloads por chave de acesso
-------------------------------------------
`productInvoiceQuery.downloadPdf/downloadXml` mandavam so o tipo binario.
No caminho feliz isso funciona -- e sempre funcionou, ao contrario do que o
diagnostico anterior registrou. O problema e o caminho de ERRO: o servidor nao
tem formatter de erro para `application/pdf` e responde 406 com corpo vazio,
apagando a mensagem. Medido em 2026-09-02 contra nfe.api.nfe.io:

  .pdf + "application/pdf"                         chave real: 200 %PDF-1.4 (7623 bytes)
                                                   inexistente: 406, corpo vazio
  .pdf + "application/pdf, application/json;q=0.9" chave real: 200 %PDF-1.4 (MESMOS bytes)
                                                   inexistente: 400 "access key is not valid"

O tipo binario continua em primeiro, entao o caminho feliz nao muda -- mesmo
status, mesmo content-type, mesmo numero de bytes. O `Buffer` de retorno segue
valendo porque o content-type de sucesso nao mudou.

2) O extrator de mensagem de erro ignorava dois envelopes reais
---------------------------------------------------------------
Com o Accept corrigido o erro chegava com corpo JSON -- e o SDK jogava a
mensagem fora assim mesmo. `extractErrorMessage` so lia
`message`/`error`/`detail`/`details`. A plataforma usa quatro envelopes:

  "pageCount must be between 1 and 50"                     string JSON crua
  {"code":40001,"message":"environment has to be ..."}     campo message
  {"errors":[{"message":"access key is not valid"}]}       lista (hosts de consulta)
  {"title":"...","errors":{"file":["The File field ..."]}} ProblemDetails/ModelState

Nos dois ultimos o chamador recebia `HTTP 400 error` -- literalmente o status
que ele ja tinha. Foi assim que `The File field is required.` ficou invisivel
enquanto o upload de certificado nao funcionava.

Os quatro envelopes viraram teste, mais os casos de borda (lista vazia,
varias mensagens, corpo sem nada aproveitavel).

E `tests/integration/setup.ts` passa a repassar `NFE_DATA_API_KEY`: sem a chave
de dados os hosts de consulta respondem 403, e nenhum teste de integracao podia
tocar neles.
Quatro metodos publicos apontam para rotas declaradas na OpenAPI que a
plataforma NAO serve:

  GET   /v2/companies/{id}/municipaltaxes/{mtid}/series/{serie}
  PATCH /v2/companies/{id}/municipaltaxes/{mtid}/updateprefecture
  GET   /v1/consumerinvoices/coupon/{chave}
  GET   /v1/consumerinvoices/coupon/{chave}.xml

Ate aqui o chamador recebia `NotFoundError` generico, indistinguivel de "esse
dado nao existe" -- e ia procurar defeito nos proprios dados.

Como a distincao foi feita (2026-09-02)
---------------------------------------
Comparando com um path inventado no mesmo host: 404 de corpo vazio, sem
content-type, byte a byte igual ao do path inventado, e roteamento, nao "nao
encontrado". Confirmacao independente: rota servida responde 401 SEM
credencial; as de cima respondem 404 sem credencial -- o middleware de
autenticacao nem chega a rodar. Alem disso, 90 dias de log de gateway nao tem
um unico 200 em `consumerinvoices/coupon`.

A escolha
---------
Nao remover os metodos (apagaria a informacao de que a rota existe na spec e
nao e servida) e nao bloquear no cliente (congelaria a medicao de hoje no
codigo, e ninguem lembraria de tirar o guard). A requisicao sai; SO o 404 e
enriquecido, preservando a classe do erro para nao quebrar `instanceof`. Se a
rota subir, o 200 passa intacto -- e ha teste afirmando exatamente isso.

legalPeople / naturalPeople NAO estavam quebrados
-------------------------------------------------
O diagnostico de julho registrou os 14 metodos como "400 em toda chamada". A
sonda tinha usado a empresa do .env, de id com 32 caracteres; a rota valida o
company_id como ObjectId de 24 hex. Sobre 50 empresas da mesma conta:

  30 com id de 24 hex   -> 200
  19 com id de 32 chars -> 400 "company id is not valid"

Um id de 24 hex sintetico responde `404 "Company not found."`: o validador de
formato passa e a busca e que falha. E limite do servidor -- nao ha conversao
possivel, e validar localmente so antecipa a recusa com mensagem pior.
Documentado no JSDoc dos dois recursos, com teste de integracao afirmando as
duas metades para a proxima leitura nao repetir a generalizacao.
CHANGELOG (pt-BR) da frente inteira: os cinco bugs corrigidos, os quatro
metodos depreciados por rota nao servida, a quebra do download de NFS-e, e a
correcao de registro dos DOIS que nao estavam quebrados (productInvoiceQuery e
os 14 de legalPeople/naturalPeople).

Fixture `tests/fixtures/live-contracts/unreachable-methods.json` guarda a
medicao, inclusive o metodo de discriminacao -- comparar com path inventado no
mesmo host, e conferir a resposta SEM credencial. Foi o que separou "rota nao
servida" de "registro nao encontrado", e "metodo quebrado" de "entrada
invalida". Chave de acesso, CNPJ, thumbprint e id de empresa reais nao entram:
os valores sao sinteticos ou <redigido>.

Verificacao: typecheck limpo, lint 0 erros, test:types 18/18, build ok,
dependencies segue vazio. Suite em modo CI 766 passed | 54 skipped; local
813 passed | 7 skipped, 50 arquivos, nenhum falho.
Duas superficies que nao sao codigo, mas que o usuario le como contrato.

1) Toda requisicao mentia sobre quem era
----------------------------------------
`src/core/http/client.ts` fixava `packageVersion = '3.0.0'` com um
`// TODO: Read from package.json`. O User-Agent saia como `@nfe-io/sdk@3.0.0`:
nome de pacote que NAO existe (o publicado e `nfe-io`) e versao tres majors
atras. Medido nos logs de gateway, 30 dias:

    93.995 requisicoes | 23 variantes de User-Agent | 5 majors de Node
                       | 1 unica versao de SDK reportada

As 23 variantes diferem so no Node e na plataforma. O User-Agent e o unico
sinal de adocao que a plataforma tem, e nao trazia informacao nenhuma sobre a
versao -- nao dava para saber quem migrou nem correlacionar incidente com
versao. A partir da proxima release, da.

O valor divergia em quatro lugares: 3.0.0 no User-Agent, 5.1.0 em
PACKAGE_VERSION e VERSION, 5.2.0 no package.json. E PACKAGE_NAME -- constante
PUBLICA -- dizia `@nfe-io/sdk`.

Agora ha fonte unica: `src/version.ts`, gerado do package.json por
`scripts/generate-version.ts` (ligado ao `npm run generate`). Gerado, e nao
lido em runtime, porque `require('../package.json')` quebra em bundle e acopla
o runtime ao layout do pacote. Nenhum literal de versao sobrou em `src/`, e
`tests/unit/version.test.ts` falha se algum voltar: a geracao e a conveniencia,
o teste e a garantia. Conferido no artefato construido -- `dist/` ja sai
`nfe-io@5.2.0`.

Nove exemplos de JSDoc mandavam importar de '@nfe-io/sdk'. Corrigidos; a skill
nao precisa mais avisar que o JSDoc mente.

2) A documentacao ensinava o wiring que a API recusa
-----------------------------------------------------
`docs/multi-host-routing.md` dizia que productInvoices, productInvoicesRtc,
stateTaxes, municipalTaxes, certificates, transportationInvoices e
inboundProductInvoices usavam a chave DE DADOS em api.nfse.io. E host FISCAL:
responde 403 a chave de dados. Era o mesmo defeito corrigido no roteamento
interno em b50bb74, ainda ensinado como se fosse o certo -- quem seguisse a
tabela reintroduzia o bug na propria aplicacao. Faltavam tambem taxCalculation
e o lado v2 de companies. Tabela refeita a partir do codigo.

A nota de fallback deixou de sugerir que as chaves sao alternativas: sao
complementares, cada uma responde 403 no territorio da outra.

3) Exemplos que nao compilavam
-------------------------------
README documentava `addresses.lookupByTerm()` e `addresses.search()`, removidos
na v5. A skill publicada chamava `uploadCertificate(companyId, certBuffer,
'password')`, mas a assinatura recebe um objeto. A skill tambem listava
taxCalculation entre os recursos de dataApiKey (usa a principal) e apresentava
consumerInvoiceQuery e municipalTaxes.getSeries/updatePrefecture como
funcionais -- sao rotas que a plataforma nao serve.

`tests/unit/docs-drift.test.ts` passa a falhar quando README, docs/ ou a skill
citam metodo que nao existe. Casa NOME de metodo, nao assinatura: conferir
assinatura exigiria compilar cada exemplo, e isso e change propria. Mesmo assim
pega os dois casos desta rodada -- verificado reintroduzindo o trecho antigo.

4) Um teste travava o bug no lugar
-----------------------------------
`tests/unit/http-client.test.ts` afirmava que o User-Agent continha
'@nfe-io/sdk'. Quem consertasse o nome quebrava a suite. Corrigido para afirmar
o nome real e negar o antigo.

Verificacao: typecheck limpo, lint 0 erros, test:types 18/18, build ok.
Suite em modo CI: 776 passed | 54 skipped (era 766).
Nenhum era chamado por package.json, workflow ou documentacao, e os tres
carregavam fatos que deixaram de valer ha duas majors:

  scripts/release.sh      tag e commit `v3.0.0` fixos; `git push origin v3`
                          (branch que nao existe; a mainline e master);
                          `npm view @nfe-io/sdk` (pacote que nao existe);
                          nao faz bump de versao nenhum
  scripts/release.ps1     mesmo v3.0.0 fixo; "Alguns testes podem falhar
                          (tests/core.test.ts - arquivo legado)" e "107/122
                          testes principais estao passando" -- a suite tem 830
  RELEASE_COMMANDS.sh     mesmo v3.0.0 fixo, na RAIZ do repo (o mais
                          descobrivel dos tres); apontava para o release.sh;
                          "Package renamed: nfe -> @nfe-io/sdk", que e o
                          inverso do que aconteceu

Quem rodasse qualquer um deles hoje criaria a tag errada. O .ps1 ainda
normalizava teste falhando no release -- a mesma premissa do
`continue-on-error` que a change enforce-release-gate acabou de remover.

Nada se perde: `publish.yml` ja faz tudo que eles faziam (testes, lint,
typecheck, test:types, build, verificacao dos artefatos de dist, dry-run) e
publica com provenance ao criar a release no GitHub. As tres ultimas releases
(v5.0.0, v5.1.0, v5.2.0) passaram por PR `release/vX.Y.Z`, nao por estes
scripts.

O `RELEASE_COMMANDS.sh` nao foi nomeado no pedido, mas e da mesma familia,
ficaria apontando para um arquivo apagado e e o unico dos tres na raiz.

Nenhum deles ia para o npm: `files` do package.json publica so dist, README,
CHANGELOG, MIGRATION e skills.
O passo "Check package.json version" so LIA a versao para uma saida. Criar a
release `v6.0.0` com o package.json ainda em `5.2.0` passava por ele sem
reclamar; o `npm publish` acabaria falhando por versao ja publicada, mas por
acidente -- e depois de instalar, testar, lintar e buildar tudo.

Pior no caso inverso, que nao tem rede de seguranca nenhuma: package.json em
`6.0.0` com a release criada como `v5.2.0` publica 6.0.0 no npm enquanto a
release, o CHANGELOG e as notas dizem 5.2.0.

Agora o passo compara e reprova, e subiu para logo depois do setup do Node --
falha em segundos, antes do `npm ci`. Vale para os dois gatilhos:
`release: created` traz a tag em `github.event.release.tag_name`,
`workflow_dispatch` em `inputs.tag`. O prefixo `v` e opcional.

Conferido com valores simulados: v6.0.0/6.0.0 passa, 6.0.0 sem prefixo passa,
pre-release (v6.0.0-rc.1) passa, package.json desatualizado reprova, tag
desatualizada reprova, tag vazia reprova.

Sai tambem uma referencia morta no resumo da release: o bloco condicional lia
`steps.tests.outcome`, mas o passo de testes perdeu o `id: tests` junto com o
`continue-on-error` na change enforce-release-gate. A condicao nunca era
verdadeira, e o texto repetia a justificativa falsa que aquela change removeu
("Some tests failed during CI (expected for integration tests)").
Revisao de seguranca automatizada apontou injecao de comando no passo que
acabei de adicionar, e o apontamento procede.

`${{ github.event.release.tag_name }}` dentro de um bloco `run:` e substituido
ANTES de o bash parsear a linha. Um valor com metacaractere deixa de ser dado e
vira comando. Nao e teorico -- `git check-ref-format` aceita `;`, `$(...)`,
crase, `&&` e `|` em nome de tag (so o espaco e recusado), e o `inputs.tag` do
`workflow_dispatch` nao valida coisa alguma.

O que estava em risco: este job tem `id-token: write` e `secrets.NPM_TOKEN`.
Injecao aqui exfiltra a credencial que publica um pacote que clientes instalam
-- vetor de supply chain. A barreira e ter permissao de escrita no repo, mas
conta de contribuidor comprometida e exatamente como esse tipo de ataque
comeca, e o custo de fechar e zero.

Correcao: os valores passam por `env:` e o script le variavel de ambiente. O
bash recebe o conteudo como dado e nunca o reparseia. Mesmo tratamento no
"Create GitHub Release Summary", que interpolava
`steps.package-version.outputs.version` em tres pontos -- risco menor, porque o
valor vem do package.json e ja passou pela checagem, mas e a mesma classe.

Defesa em profundidade: a tag agora precisa casar
`^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$` antes de qualquer comparacao.
Alem de barrar valor hostil, pega tag digitada errada de graca.

Verificado com payload real: `v6.0.0;touch ...`, `v6.0.0$(touch ...)`,
`v6.0.0\`touch ...\`` e `v6.0.0 && touch ...` pelo dispatch -- os quatro
reprovam e o arquivo nao e criado. Comportamento normal intacto: v6.0.0, 6.0.0
sem prefixo, pre-release, dispatch por inputs.tag, package.json desatualizado e
tag vazia continuam com o mesmo veredito de antes.

Nenhuma interpolacao `${{ }}` sobrou dentro de bloco `run:` nos dois workflows.
Major de correcao de contrato. Nenhuma funcionalidade nova: sao bugs provados
por sonda ao vivo contra a API real entre 01 e 03/09, a maioria em metodos que
nunca puderam funcionar.

E major porque nove pontos da superficie publica mudam de tipo ou assinatura --
medido comparando o dist/index.d.ts com o construido a partir da v5.2.0
publicada, nao por estimativa:

  serviceInvoices.downloadPdf/downloadXml     invoiceId? -> obrigatorio
  inbound.getXml/getPdf/getEventXml           string -> InboundFileResource
  transportationInvoices.download*            string -> InboundFileResource
  consumerInvoices.download*                  Buffer -> ConsumerInvoiceFileResource
  consumerInvoices.getItems/getEvents         environment? -> options?, retorno novo
  consumerInvoices.cancel                     retorno novo
  companies.getCertificateStatus              inline -> CertificateStatusSummary
  PACKAGE_NAME                                '@nfe-io/sdk' -> 'nfe-io'
  wiring de credencial                        ConfigurationError em vez de 403

Zero metodos removidos. Na pratica quase ninguem precisa mexer: as quebras sao
em superficies que ja estavam quebradas -- metodos que so respondiam 404,
retornos que vinham undefined, tipos que mentiam sobre o conteudo.

Dois tipos vazavam
------------------
`CertificateStatusSummary` e `ConsumerInvoicePageOptions` apareciam em
assinatura publica e NAO estavam na lista de exports: o chamador nao conseguia
nomear o retorno de `getCertificateStatus()` nem o argumento de
`getItems()`/`getEvents()`. Exportados. Achado ao montar o guia de migracao --
escrever a migracao e o que faz alguem ler a superficie do lado de fora.

O bump em si
------------
package.json 5.2.0 -> 6.0.0, e `src/version.ts` acompanhou sozinho via
`npm run generate:version`. Simulado o portao do publish.yml contra este
estado: tag `v6.0.0` passa, `v5.2.0` reprova.

CHANGELOG com secao [6.0.0] datada, MIGRATION.md com o roteiro completo v5 -> v6
(sete quebras, quatro depreciados e a restricao de id que e do servidor), e o
badge do README apontando para a nova secao.

Verificacao: typecheck limpo, lint 0 erros, test:types 18/18, build ok,
776 passed | 54 skipped.
Arquivo temporario, para ser apagado depois de abrir o PR. Fica versionado
para voce revisar o texto antes de publicar, e para o comando de abertura
(`gh pr create --body-file`) ter de onde ler.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📋 OpenAPI Spec Validation

✅ All specs validated and types generated successfully

Specs processed:

  • calculo-impostos-v1.yaml - 27.90 KB, 853 lines
  • consulta-cnpj.yaml - 34.28 KB, 1128 lines
  • consulta-cpf.yaml - 3.39 KB, 83 lines
  • consulta-cte-v2.yaml - 18.33 KB, 578 lines
  • consulta-endereco.yaml - 11.17 KB, 343 lines
  • consulta-nf-consumidor.yaml - 43.41 KB, 1279 lines
  • consulta-nf.yaml - 137.87 KB, 3119 lines
  • consulta-nfe-distribuicao-v1.yaml - 53.07 KB, 1775 lines
  • nf-consumidor-v2.yaml - 293.87 KB, 7609 lines
  • nf-produto-v2.yaml - 309.41 KB, 8204 lines
  • nf-servico-v1.yaml - 257.67 KB, 6255 lines
  • nfeio.yaml - 15.86 KB, 630 lines
  • product-invoice-rtc-v1.yaml - 198.88 KB, 5084 lines
  • service-invoice-rtc-v1.yaml - 95.30 KB, 1728 lines

Generated types available as artifact in src/generated/.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📋 OpenAPI Spec Validation

✅ All specs validated and types generated successfully

Specs processed:

  • calculo-impostos-v1.yaml - 27.90 KB, 853 lines
  • consulta-cnpj.yaml - 34.28 KB, 1128 lines
  • consulta-cpf.yaml - 3.39 KB, 83 lines
  • consulta-cte-v2.yaml - 18.33 KB, 578 lines
  • consulta-endereco.yaml - 11.17 KB, 343 lines
  • consulta-nf-consumidor.yaml - 43.41 KB, 1279 lines
  • consulta-nf.yaml - 137.87 KB, 3119 lines
  • consulta-nfe-distribuicao-v1.yaml - 53.07 KB, 1775 lines
  • nf-consumidor-v2.yaml - 293.87 KB, 7609 lines
  • nf-produto-v2.yaml - 309.41 KB, 8204 lines
  • nf-servico-v1.yaml - 257.67 KB, 6255 lines
  • nfeio.yaml - 15.86 KB, 630 lines
  • product-invoice-rtc-v1.yaml - 198.88 KB, 5084 lines
  • service-invoice-rtc-v1.yaml - 95.30 KB, 1728 lines

Generated types available as artifact in src/generated/.

@andrenfe
andrenfe merged commit e0a705f into master Sep 3, 2026
13 checks passed
@andrenfe
andrenfe deleted the release/v6.0.0 branch September 3, 2026 21:23
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