From 3ffe83fb0b8d55b735afe8d6b6f482f6fed31bbf Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 3 Jul 2026 12:29:20 -0300 Subject: [PATCH 1/6] fix(webhooks): corrigir contrato de webhooks de conta contra a API real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonda ao vivo (2026-07-02/03, 3 contas) provou que o contrato de webhooks divergia da API em dois pontos: - /v1/companies/{id}/webhooks (métodos company-scoped) retorna 404 em todas as contas testadas. Contrato alucinado no rewrite v3, nunca existiu na API, nos specs ou no SDK legado v2. Métodos marcados @deprecated (comportamento inalterado, remoção na próxima major). - createAccountWebhook/updateAccountWebhook/retrieveAccountWebhook (v5.0.0) ignoravam o envelope { "webHook": {...} } exigido pela API: create sem envelope sempre respondia 400. Corrigido nos dois sentidos (wrap no request, unwrap na resposta, com fallback defensivo). Tipo AccountWebhook novo com o shape real (uri/contentType/secret/ filters/insecureSsl/headers/properties/status), já presente nos specs oficiais (nf-servico-v1.yaml e equivalentes) e nos tipos gerados — o resource manuscrito só não os consultava. WebhookEventType substitui os literais invoice.* (inexistentes na API) pelos 46 event types reais (service_invoice.*, product_invoice.*, consumer_invoice.*). Achado da contraprova: PUT /v2/webhooks/{id} é substituição integral — update sem `status` desativa o webhook. Documentado no JSDoc do updateAccountWebhook com exemplo partindo do retrieve. Teste de alinhamento (tests/types/account-webhook-alignment.test-d.ts) amarra o AccountWebhook ao schema gerado do spec, para que um sync de spec que mude o contrato quebre o build em vez de driftar em silêncio. Docs, exemplos e a skill nfeio-node-sdk atualizados para o fluxo account-scoped. --- CHANGELOG.md | 44 ++++++ README.md | 22 +-- docs/API.md | 67 +++++---- docs/recursos/webhooks.md | 35 +++-- docs/webhooks.md | 20 ++- examples/all-resources-demo.js | 27 ++-- examples/jsdoc-intellisense-demo.ts | 16 ++- examples/real-world-webhooks.js | 78 +++++----- skills/nfeio-node-sdk/SKILL.md | 27 +++- src/core/client.ts | 19 ++- src/core/resources/webhooks.ts | 134 ++++++++++++++---- src/core/types.ts | 104 ++++++++++++++ src/index.ts | 4 +- .../types/account-webhook-alignment.test-d.ts | 75 ++++++++++ tests/unit/webhooks.test.ts | 85 +++++++++-- 15 files changed, 585 insertions(+), 172 deletions(-) create mode 100644 tests/types/account-webhook-alignment.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a4118..1b42a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ Todas as mudanças notáveis neste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). +## [5.1.0] - Não lançado + +> Correção do contrato de webhooks contra a API real, provado por sonda ao vivo +> (2026-07-02, duas contas). O contrato correto sempre esteve nos specs oficiais +> (`openapi/spec/nf-servico-v1.yaml` e equivalentes) — o recurso manuscrito havia +> divergido deles. + +### Corrigido + +- **`createAccountWebhook` funcionava 0% das vezes**: a API exige o request + envelopado em `{ "webHook": {...} }` (sem ele responde + `400 "missing required properties including: 'webHook'"`) e devolve a resposta + também envelopada. O SDK agora envelopa o request (create/update) e desembrulha + as respostas (create/retrieve/update), com fallback defensivo para corpo cru. +- `listAccountWebhooks`/`retrieveAccountWebhook`/`updateAccountWebhook` agora + tipados com o shape real do recurso (ver `AccountWebhook` abaixo). + +### Adicionado + +- Tipo **`AccountWebhook`** com o shape real da API: `uri`, `contentType`, + `secret` (32–64 caracteres, ecoado no create e omitido nas leituras), `filters`, + `insecureSsl`, `headers`, `properties`, `status`, `createdOn`, `modifiedOn`. + Nota: o spec declara `contentType`/`status` como enums inteiros, mas a API + serializa strings (`"json"`, `"Active"`) — o tipo segue o fio real. +- Tipo **`WebhookEventType`** (união aberta) com os 46 event types reais de + `GET /v2/webhooks/eventTypes` (`service_invoice.issued_successfully`, etc.). +- Teste de alinhamento (`tests/types/account-webhook-alignment.test-d.ts`) + amarrando o `AccountWebhook` ao schema gerado do spec oficial — um sync de spec + que mude o contrato de webhooks quebra o `npm run test:types` em vez de driftar. +- JSDoc do `createAccountWebhook` documenta a verificação de URI na criação + (a NFE.io faz um ping e exige resposta 2xx). +- JSDoc do `updateAccountWebhook` documenta que o `PUT` é substituição integral + (confirmado ao vivo em 2026-07-03): campos omitidos voltam ao padrão — update + sem `status` **desativa o webhook**. Envie o objeto completo (parta do retrieve). + +### Deprecado + +- Métodos company-scoped de webhooks (`list`, `create`, `retrieve`, `update`, + `delete`, `test` sobre `/v1/companies/{id}/webhooks`): a rota retorna **404** + na API atual (confirmado em duas contas, 2026-07-02). Use os equivalentes + account-scoped. O comportamento não mudou; remoção fica para a próxima major. +- Tipos `Webhook` e `WebhookEvent`: shapes que a API real rejeita. Use + `AccountWebhook` e `WebhookEventType`. + ## [5.0.0] - 2026-06-30 > Primeira release de **funcionalidades** desde a v3 — a v4 foi apenas o bump de runtime diff --git a/README.md b/README.md index 05ac7d1..584a8a8 100644 --- a/README.md +++ b/README.md @@ -323,22 +323,24 @@ const pessoa = await nfe.naturalPeople.findByTaxNumber(empresaId, '12345678901') #### 🔗 Webhooks (`nfe.webhooks`) -Gerenciar configurações de webhook: +Gerenciar configurações de webhook. Webhooks são gerenciados **por conta** +(`/v2/webhooks`) — os métodos por empresa estão deprecated (a rota retorna 404): ```typescript -// Criar webhook -const webhook = await nfe.webhooks.create(empresaId, { - url: 'https://meuapp.com.br/webhooks/nfe', - events: ['invoice.issued', 'invoice.cancelled'], - active: true +// Criar webhook — a URI precisa responder 2xx já na criação (ping de verificação) +const webhook = await nfe.webhooks.createAccountWebhook({ + uri: 'https://meuapp.com.br/webhooks/nfe', + contentType: 'json', + secret: 'um-segredo-de-32-a-64-caracteres-aqui', + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'] }); -// Listar webhooks -const webhooks = await nfe.webhooks.list(empresaId); +// Listar webhooks da conta +const webhooks = await nfe.webhooks.listAccountWebhooks(); // Atualizar webhook -await nfe.webhooks.update(empresaId, webhookId, { - events: ['invoice.issued'] +await nfe.webhooks.updateAccountWebhook(webhookId, { + filters: ['service_invoice.issued_successfully'] }); // Validar assinatura do webhook diff --git a/docs/API.md b/docs/API.md index aae3b3f..274c5cf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1425,54 +1425,72 @@ const person = await nfe.naturalPeople.findByTaxNumber('company-id', '1234567890 **Resource:** `nfe.webhooks` -Webhook configuration and management. +Webhook configuration and management. Webhooks are **account-scoped** +(`/v2/webhooks`) — the methods take no `companyId`. -#### `create(data: Partial): Promise` +> ⚠ **Deprecated:** the company-scoped methods (`create/list/retrieve/update/delete/test(companyId, ...)`) +> target `/v1/companies/{id}/webhooks`, which returns **404** on the current API. +> Use the account-scoped methods below. -Create a webhook. +#### `createAccountWebhook(data: AccountWebhook): Promise` + +Create an account webhook. NFE.io **verifies the `uri` at creation time** with a +ping that must receive a 2xx response — the endpoint must already be live. The +`secret` must be 32–64 characters (echoed in the create response, omitted on reads). ```typescript -const webhook = await nfe.webhooks.create({ - url: 'https://example.com/webhook', - events: ['invoice.issued', 'invoice.cancelled'], - secret: 'webhook-secret' +const webhook = await nfe.webhooks.createAccountWebhook({ + uri: 'https://example.com/webhook', // must answer 2xx at creation time + contentType: 'json', + secret: 'a-secret-with-32-to-64-characters-x', + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'] }); ``` -#### `list(options?: PaginationOptions): Promise>` +#### `listAccountWebhooks(): Promise>` -List all webhooks. +List all account webhooks. ```typescript -const webhooks = await nfe.webhooks.list(); +const webhooks = await nfe.webhooks.listAccountWebhooks(); ``` -#### `retrieve(webhookId: string): Promise` +#### `retrieveAccountWebhook(webhookId: string): Promise` Get a specific webhook. ```typescript -const webhook = await nfe.webhooks.retrieve('webhook-id'); +const webhook = await nfe.webhooks.retrieveAccountWebhook('webhook-id'); ``` -#### `update(webhookId: string, data: Partial): Promise` +#### `updateAccountWebhook(webhookId: string, data: Partial): Promise` Update webhook configuration. +> ⚠ `PUT` is a **full replacement**: omitted fields reset to their defaults — an +> update without `status` **deactivates the webhook** (`status` becomes +> `"Inactive"`). Send the complete object, e.g. starting from a retrieve: + ```typescript -const updated = await nfe.webhooks.update('webhook-id', { - events: ['invoice.issued', 'invoice.cancelled', 'invoice.error'] +const current = await nfe.webhooks.retrieveAccountWebhook('webhook-id'); +const updated = await nfe.webhooks.updateAccountWebhook('webhook-id', { + ...current, + filters: ['service_invoice.issued_successfully', 'service_invoice.issued_error'] }); ``` -#### `delete(webhookId: string): Promise` +#### `deleteAccountWebhook(webhookId: string): Promise` Delete a webhook. ```typescript -await nfe.webhooks.delete('webhook-id'); +await nfe.webhooks.deleteAccountWebhook('webhook-id'); ``` +#### `deleteAllAccountWebhooks(): Promise` + +⚠ **Destructive:** removes **all** webhooks on the account. + #### `validateSignature(payload: Buffer | string, signature: string | string[] | undefined, secret: string): boolean` Validate the signature on a webhook delivery from NFE.io. @@ -1503,21 +1521,22 @@ app.post( ); ``` -#### `test(webhookId: string): Promise` +#### `pingAccountWebhook(webhookId: string): Promise` -Test webhook delivery. +Trigger a test ping for a webhook. ```typescript -await nfe.webhooks.test('webhook-id'); +await nfe.webhooks.pingAccountWebhook('webhook-id'); ``` -#### `getAvailableEvents(): Promise` +#### `fetchEventTypes(): Promise` -Get list of available webhook event types. +Fetch the live list of available webhook event types from the API (prefer this +over the deprecated hardcoded `getAvailableEvents()`). ```typescript -const events = await nfe.webhooks.getAvailableEvents(); -// ['invoice.issued', 'invoice.cancelled', ...] +const events = await nfe.webhooks.fetchEventTypes(); +// ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully', ...] ``` --- diff --git a/docs/recursos/webhooks.md b/docs/recursos/webhooks.md index ca63964..b5d9ac1 100644 --- a/docs/recursos/webhooks.md +++ b/docs/recursos/webhooks.md @@ -3,7 +3,7 @@ title: Webhooks (recurso) sidebar_label: Webhooks sidebar_position: 9 slug: recurso-webhooks -description: Métodos de webhooks por empresa e por conta, verificação de assinatura e lista de eventos ao vivo com nfe.webhooks. +description: Métodos de webhooks por conta, verificação de assinatura e lista de eventos ao vivo com nfe.webhooks. --- # Webhooks (recurso) @@ -12,15 +12,9 @@ description: Métodos de webhooks por empresa e por conta, verificação de assi entrega. Para o guia conceitual (assinatura HMAC, `express.raw`), veja [Webhooks](../webhooks.md). -## Métodos — por empresa (`/companies/{id}/webhooks`) - -| Método | Descrição | -|---|---| -| `list(companyId)` | Lista os webhooks da empresa. | -| `create(companyId, data)` | Cria um webhook. | -| `retrieve(companyId, webhookId)` / `update(...)` / `delete(...)` | CRUD. | -| `test(companyId, webhookId)` | Dispara um teste. | -| `validateSignature(payload, signature, secret)` | Valida a assinatura HMAC-SHA1 (`x-hub-signature`). | +Webhooks são gerenciados **por conta** (`/v2/webhooks`). Os métodos por empresa +(`list/create/retrieve/update/delete/test(companyId, ...)`) estão **deprecated**: +a rota `/v1/companies/{id}/webhooks` retorna 404 na API atual. ## Métodos — por conta (`/v2/webhooks`, sem `companyId`) @@ -32,6 +26,7 @@ entrega. Para o guia conceitual (assinatura HMAC, `express.raw`), veja | `pingAccountWebhook(id)` | Dispara um ping de teste. | | `deleteAllAccountWebhooks()` | ⚠️ Remove **todos** os webhooks da conta. | | `fetchEventTypes()` | Lista de tipos de evento **ao vivo** (`string[]`). | +| `validateSignature(payload, signature, secret)` | Valida a assinatura HMAC-SHA1 (`x-hub-signature`). | ## Exemplo @@ -39,12 +34,26 @@ entrega. Para o guia conceitual (assinatura HMAC, `express.raw`), veja const eventTypes = await nfe.webhooks.fetchEventTypes(); const created = await nfe.webhooks.createAccountWebhook({ - uri: 'https://seu-site.com/webhook', - events: eventTypes.slice(0, 2), + uri: 'https://seu-site.com/webhook', // precisa responder 2xx já na criação (ping) + contentType: 'json', + secret: 'um-segredo-de-32-a-64-caracteres-aqui', + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'], }); -await nfe.webhooks.pingAccountWebhook(created.id); +if (created.id) await nfe.webhooks.pingAccountWebhook(created.id); ``` +:::info Verificação na criação +Ao criar um webhook, a NFE.io faz um ping na `uri` e exige resposta **2xx** — +o endpoint precisa estar no ar antes do `createAccountWebhook`. O `secret` +(32–64 caracteres) é ecoado na resposta do create, mas omitido nas leituras. +::: + +:::caution Update é substituição integral +`updateAccountWebhook` faz um `PUT`: campos omitidos voltam ao padrão — um +update sem `status` **desativa o webhook**. Parta do `retrieveAccountWebhook` +e envie o objeto completo. +::: + ## Próximos passos - [Webhooks (guia)](../webhooks.md) diff --git a/docs/webhooks.md b/docs/webhooks.md index 3e6e68d..ab42925 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -41,17 +41,23 @@ verificação falha. Use `express.raw()` e passe o `Buffer`. A comparação é feita com `timingSafeEqual` (resistente a timing attacks) e aceita a assinatura como `string` ou `string[]`. -## Webhooks por empresa vs por conta +## Webhooks são por conta -| Escopo | Acesso | Caminho | -|---|---|---| -| Empresa | `nfe.webhooks.list/create/retrieve/update/delete(companyId, ...)` | `/companies/{id}/webhooks` | -| Conta | `nfe.webhooks.listAccountWebhooks/createAccountWebhook/...` | `/v2/webhooks` (host-root) | - -Métodos de conta (sem `companyId`): `listAccountWebhooks`, `createAccountWebhook`, +Webhooks são registrados e gerenciados **por conta** (`/v2/webhooks`), sem +`companyId`: `listAccountWebhooks`, `createAccountWebhook`, `retrieveAccountWebhook`, `updateAccountWebhook`, `deleteAccountWebhook`, `pingAccountWebhook` e `deleteAllAccountWebhooks` (⚠️ remove **todos**). +Na criação, a NFE.io **verifica a `uri`** com um ping que exige resposta 2xx — +o endpoint precisa estar no ar antes do `createAccountWebhook`. O `secret` +deve ter 32–64 caracteres. + +:::caution Métodos por empresa deprecated +`nfe.webhooks.list/create/retrieve/update/delete/test(companyId, ...)` estão +**deprecated**: a rota `/v1/companies/{id}/webhooks` retorna 404 na API atual. +Use os métodos por conta acima. +::: + ## Tipos de evento ao vivo Prefira `fetchEventTypes()` (fonte da verdade é o servidor) ao invés da lista diff --git a/examples/all-resources-demo.js b/examples/all-resources-demo.js index 87d341f..c92525e 100644 --- a/examples/all-resources-demo.js +++ b/examples/all-resources-demo.js @@ -134,22 +134,23 @@ const naturalPerson = await nfe.naturalPeople.create('company-id', { console.log('\n5️⃣ WEBHOOKS - Notificações de Eventos'); console.log('─'.repeat(50)); - console.log('Funcionalidades:'); - console.log('✓ list(companyId) - Listar webhooks'); - console.log('✓ create(companyId, data) - Criar webhook'); - console.log('✓ retrieve(companyId, id) - Buscar webhook'); - console.log('✓ update(companyId, id, data) - Atualizar'); - console.log('✓ delete(companyId, id) - Deletar'); - console.log('✓ test(companyId, id) - Testar webhook'); + console.log('Funcionalidades (escopo CONTA — /v2/webhooks):'); + console.log('✓ listAccountWebhooks() - Listar webhooks'); + console.log('✓ createAccountWebhook(data) - Criar webhook'); + console.log('✓ retrieveAccountWebhook(id) - Buscar webhook'); + console.log('✓ updateAccountWebhook(id, data) - Atualizar'); + console.log('✓ deleteAccountWebhook(id) - Deletar'); + console.log('✓ pingAccountWebhook(id) - Testar webhook'); console.log('✓ validateSignature() - Validar assinatura'); - console.log('✓ getAvailableEvents() - Eventos disponíveis\n'); + console.log('✓ fetchEventTypes() - Eventos disponíveis (lista viva)\n'); - console.log('Exemplo de criação:'); + console.log('Exemplo de criação (escopo CONTA — a uri precisa responder 2xx na criação):'); console.log(` -const webhook = await nfe.webhooks.create('company-id', { - url: 'https://seu-site.com/webhook/nfe', - events: ['invoice.issued', 'invoice.cancelled'], - secret: 'sua-chave-secreta' +const webhook = await nfe.webhooks.createAccountWebhook({ + uri: 'https://seu-site.com/webhook/nfe', + contentType: 'json', + secret: 'um-segredo-de-32-a-64-caracteres-aqui', + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'] }); `); diff --git a/examples/jsdoc-intellisense-demo.ts b/examples/jsdoc-intellisense-demo.ts index fa252af..f27958b 100644 --- a/examples/jsdoc-intellisense-demo.ts +++ b/examples/jsdoc-intellisense-demo.ts @@ -110,18 +110,20 @@ async function demonstrateJSDoc() { const envClient = createClientFromEnv('production'); // Example 7: Resource-specific operations with docs - // All webhook methods have comprehensive documentation - const webhook = await nfe.webhooks.create(companyId, { - url: 'https://example.com/webhook', - events: ['invoice.issued', 'invoice.cancelled'], - secret: 'webhook-secret' + // All webhook methods have comprehensive documentation. + // Webhooks are account-scoped (/v2/webhooks); the uri must answer 2xx at creation. + const webhook = await nfe.webhooks.createAccountWebhook({ + uri: 'https://example.com/webhook', + contentType: 'json', + secret: 'um-segredo-de-32-a-64-caracteres-aqui', + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'], }); // Hover over "validateSignature" to see HMAC validation docs const isValid = nfe.webhooks.validateSignature( - '{"event": "invoice.issued"}', + '{"event": "service_invoice.issued_successfully"}', 'signature-from-header', - 'webhook-secret' + 'um-segredo-de-32-a-64-caracteres-aqui' ); // Example 8: Company operations with certificate upload diff --git a/examples/real-world-webhooks.js b/examples/real-world-webhooks.js index 11cf7aa..c160ab1 100644 --- a/examples/real-world-webhooks.js +++ b/examples/real-world-webhooks.js @@ -40,28 +40,20 @@ async function configurarWebhooks() { const empresa = await nfe.companies.retrieve(companyId); console.log(`✅ Empresa: ${empresa.name}`); - // 2. Listar webhooks existentes - console.log('\n📋 2. Listando webhooks configurados...'); - let webhooks = { data: [] }; - try { - webhooks = await nfe.webhooks.list(companyId); - } catch (error) { - // API retorna 404 quando não há webhooks configurados - if (error.status === 404 || error.type === 'NotFoundError') { - console.log('⚠️ Nenhum webhook configurado ainda'); - } else { - throw error; - } - } + // 2. Listar webhooks existentes (webhooks são gerenciados POR CONTA — /v2/webhooks) + console.log('\n📋 2. Listando webhooks configurados na conta...'); + const webhooks = await nfe.webhooks.listAccountWebhooks(); if (webhooks.data && webhooks.data.length > 0) { console.log(`✅ ${webhooks.data.length} webhook(s) encontrado(s):`); webhooks.data.forEach((webhook, index) => { - console.log(` ${index + 1}. URL: ${webhook.url}`); - console.log(` Status: ${webhook.active ? 'Ativo' : 'Inativo'}`); - console.log(` Eventos: ${webhook.events?.join(', ') || 'N/A'}`); + console.log(` ${index + 1}. URI: ${webhook.uri}`); + console.log(` Status: ${webhook.status}`); + console.log(` Filtros: ${webhook.filters?.join(', ') || 'N/A'}`); console.log(' ' + '─'.repeat(60)); }); + } else { + console.log('⚠️ Nenhum webhook configurado ainda'); } // 3. Criar novo webhook (ou usar existente) @@ -74,7 +66,7 @@ async function configurarWebhooks() { console.log(' Para produção, substitua pela URL real do seu servidor!'); let webhook; - const webhookExistente = webhooks.data?.find(w => w.url === webhookUrl); + const webhookExistente = webhooks.data?.find(w => w.uri === webhookUrl); if (webhookExistente) { console.log('✅ Webhook já existe, usando configuração existente'); @@ -83,26 +75,26 @@ async function configurarWebhooks() { console.log('⚠️ Criando novo webhook...'); try { - webhook = await nfe.webhooks.create(companyId, { - url: webhookUrl, - events: [ - 'invoice.issued', - 'invoice.cancelled', - 'invoice.error' - ], - active: true + // ATENÇÃO: na criação a NFE.io faz um ping na URI e exige resposta 2xx — + // o endpoint precisa estar no ar. O secret deve ter 32–64 caracteres. + webhook = await nfe.webhooks.createAccountWebhook({ + uri: webhookUrl, + contentType: 'json', + secret: process.env.NFE_WEBHOOK_SECRET || 'um-segredo-de-32-a-64-caracteres-aqui', + filters: [ + 'service_invoice.issued_successfully', + 'service_invoice.issued_error', + 'service_invoice.cancelled_successfully' + ] }); console.log('✅ Webhook criado com sucesso!'); console.log(` ID: ${webhook.id}`); - console.log(` URL: ${webhook.url}`); - console.log(` Eventos: ${webhook.events?.join(', ')}`); + console.log(` URI: ${webhook.uri}`); + console.log(` Filtros: ${webhook.filters?.join(', ')}`); } catch (error) { if (error.status === 400 || error.status === 409 || error.type === 'ValidationError') { - console.warn('⚠️ Webhook já existe ou URL inválida'); - console.warn(' Continue para ver exemplo de validação de assinatura'); - } else if (error.status === 404 || error.type === 'NotFoundError') { - console.warn('⚠️ Recurso não encontrado - webhooks podem não estar disponíveis neste ambiente'); + console.warn('⚠️ URI inválida ou não respondeu 2xx ao ping de verificação'); console.warn(' Continue para ver exemplo de validação de assinatura'); } else { throw error; @@ -115,8 +107,8 @@ async function configurarWebhooks() { console.log('\n📋 4. Exemplo de atualização de webhook...'); console.log(' (não executado neste exemplo, mas o código está disponível)'); console.log('\n Código para atualizar:'); - console.log(` await nfe.webhooks.update('${companyId}', '${webhook.id}', {`); - console.log(` events: ['invoice.issued', 'invoice.cancelled']`); + console.log(` await nfe.webhooks.updateAccountWebhook('${webhook.id}', {`); + console.log(` filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully']`); console.log(` });`); } @@ -126,7 +118,7 @@ async function configurarWebhooks() { // Exemplo de payload que você receberá no seu endpoint const examplePayload = { - event: 'invoice.issued', + event: 'service_invoice.issued_successfully', data: { id: 'nota-fiscal-id-123', number: '12345', @@ -169,17 +161,17 @@ async function configurarWebhooks() { console.log(' const { event, data } = payload;'); console.log(' '); console.log(' switch (event) {'); - console.log(' case "invoice.issued":'); + console.log(' case "service_invoice.issued_successfully":'); console.log(' console.log("Nota fiscal emitida:", data.id);'); console.log(' // Sua lógica aqui'); console.log(' break;'); console.log(' '); - console.log(' case "invoice.cancelled":'); + console.log(' case "service_invoice.cancelled_successfully":'); console.log(' console.log("Nota fiscal cancelada:", data.id);'); console.log(' // Sua lógica aqui'); console.log(' break;'); console.log(' '); - console.log(' case "invoice.error":'); + console.log(' case "service_invoice.issued_error":'); console.log(' console.error("Erro ao emitir nota:", data.error);'); console.log(' // Sua lógica de tratamento de erro'); console.log(' break;'); @@ -189,14 +181,12 @@ async function configurarWebhooks() { console.log('});'); console.log('```'); - // 6. Tipos de eventos disponíveis - console.log('\n📋 6. Eventos disponíveis para webhooks:'); + // 6. Tipos de eventos disponíveis (lista viva do servidor) + console.log('\n📋 6. Eventos disponíveis para webhooks (via fetchEventTypes):'); console.log('═'.repeat(70)); - console.log(' • invoice.issued - Nota fiscal emitida com sucesso'); - console.log(' • invoice.cancelled - Nota fiscal cancelada'); - console.log(' • invoice.error - Erro ao processar nota fiscal'); - console.log(' • invoice.authorized - Nota fiscal autorizada pela prefeitura'); - console.log(' • invoice.rejected - Nota fiscal rejeitada pela prefeitura'); + const eventTypes = await nfe.webhooks.fetchEventTypes(); + eventTypes.slice(0, 10).forEach((id) => console.log(` • ${id}`)); + console.log(` ... (${eventTypes.length} no total — use nfe.webhooks.fetchEventTypes())`); // 7. Melhores práticas console.log('\n💡 Melhores Práticas para Webhooks:'); diff --git a/skills/nfeio-node-sdk/SKILL.md b/skills/nfeio-node-sdk/SKILL.md index bb11516..166b89c 100644 --- a/skills/nfeio-node-sdk/SKILL.md +++ b/skills/nfeio-node-sdk/SKILL.md @@ -287,14 +287,24 @@ const expiring = await nfe.companies.getCompaniesWithExpiringCertificates(30); / ## Core Pattern: Webhooks +Webhooks are **account-scoped** (`/v2/webhooks`) — no `companyId`. The company-scoped +methods (`nfe.webhooks.create(companyId, ...)` etc.) are **deprecated**: the +`/v1/companies/{id}/webhooks` route 404s on the current API. + ```typescript -// Create webhook -const webhook = await nfe.webhooks.create(companyId, { - url: 'https://your-app.com/webhooks/nfe', - events: ['invoice.created', 'invoice.issued', 'invoice.cancelled', 'invoice.failed'], - active: true, +// Create an account webhook. NFE.io PINGS the uri at creation time and requires +// a 2xx response — the endpoint must already be live. secret: 32–64 chars. +const webhook = await nfe.webhooks.createAccountWebhook({ + uri: 'https://your-app.com/webhooks/nfe', // uri, NOT url + contentType: 'json', + secret: 'a-secret-with-32-to-64-characters-x', + filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'], }); +// Other account methods: listAccountWebhooks(), retrieveAccountWebhook(id), +// updateAccountWebhook(id, data), deleteAccountWebhook(id), pingAccountWebhook(id), +// deleteAllAccountWebhooks() (⚠️ removes ALL), fetchEventTypes() (live list). + // Validate incoming webhook signature (in your handler). // IMPORTANT: pass req.body as a Buffer (use express.raw()) — NOT JSON.stringify(req.body). // NFE.io signs the raw body bytes; re-serializing JSON will produce different bytes. @@ -307,7 +317,10 @@ const isValid = nfe.webhooks.validateSignature( // Useful delivery headers: x-hook-id (idempotency key), x-hook-attempts (retry counter). ``` -Available events: `invoice.created`, `invoice.issued`, `invoice.cancelled`, `invoice.failed`. +Event types follow `service_invoice.*` / `product_invoice.*` / `consumer_invoice.*` +patterns (e.g. `service_invoice.issued_successfully`, `service_invoice.issued_error`, +`service_invoice.cancelled_successfully`). The legacy `invoice.*` literals do NOT exist +on the live API — fetch the real list with `await nfe.webhooks.fetchEventTypes()`. ## Critical Pitfalls @@ -353,7 +366,7 @@ Available events: `invoice.created`, `invoice.issued`, `invoice.cancelled`, `inv | Manage companies & certificates | `nfe.companies.*` | | Manage people (PJ) under company | `nfe.legalPeople.*` | | Manage people (PF) under company | `nfe.naturalPeople.*` | -| Set up webhook notifications | `nfe.webhooks.create(companyId, {...})` | +| Set up webhook notifications | `nfe.webhooks.createAccountWebhook({ uri, secret, filters })` | | Manage state tax registrations (IE) | `nfe.stateTaxes.*` | | Cancel a service invoice | `nfe.serviceInvoices.cancelAndWait(companyId, invoiceId)` (async; `cancel()` retorna união discriminada) | | Cancel a product invoice | `nfe.productInvoices.cancel(companyId, invoiceId)` | diff --git a/src/core/client.ts b/src/core/client.ts index 74dc6bd..ed64ff3 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -301,20 +301,25 @@ export class NfeClient { * Webhooks API resource * * @description - * Provides operations for managing webhooks: - * - CRUD operations for webhook configurations + * Provides operations for managing webhooks (account-scoped, `/v2/webhooks`): + * - CRUD operations for webhook configurations (`*AccountWebhook*` methods) * - Webhook signature validation - * - Test webhook delivery - * - List available event types + * - Ping/test webhook delivery + * - Fetch available event types from the live API + * + * The company-scoped methods (`create`, `list`, ...) are deprecated — the + * `/v1/companies/{id}/webhooks` route returns 404 on the current API. * * @see {@link WebhooksResource} * @throws {ConfigurationError} If API key is not configured * * @example * ```typescript - * const webhook = await nfe.webhooks.create({ - * url: 'https://example.com/webhook', - * events: ['invoice.issued', 'invoice.cancelled'] + * const webhook = await nfe.webhooks.createAccountWebhook({ + * uri: 'https://example.com/webhook', // precisa responder 2xx já na criação + * contentType: 'json', + * secret: 'um-segredo-de-32-a-64-caracteres-aqui', + * filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'], * }); * ``` */ diff --git a/src/core/resources/webhooks.ts b/src/core/resources/webhooks.ts index 14768f9..bb47134 100644 --- a/src/core/resources/webhooks.ts +++ b/src/core/resources/webhooks.ts @@ -6,11 +6,23 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import type { HttpClient } from '../http/client.js'; -import type { Webhook, WebhookEvent, ListResponse, ResourceId } from '../types.js'; +import type { + AccountWebhook, + Webhook, + WebhookEvent, + WebhookEventType, + ListResponse, + ResourceId, +} from '../types.js'; /** - * Webhooks resource for managing event subscriptions - * All operations are scoped by company_id + * Webhooks resource for managing event subscriptions. + * + * Webhooks are managed at the **account** level (`/v2/webhooks`) — use the + * `*AccountWebhook*` methods. The company-scoped methods (`list`, `create`, + * `retrieve`, `update`, `delete`, `test`) are deprecated: the route + * `/v1/companies/{id}/webhooks` returns 404 on the current API (confirmed on + * two accounts, 2026-07-02). */ export class WebhooksResource { /** @@ -28,7 +40,10 @@ export class WebhooksResource { /** * List all webhooks for a company - * + * + * @deprecated A rota `/v1/companies/{id}/webhooks` retorna 404 na API atual + * (confirmado em duas contas, 2026-07-02). Use {@link listAccountWebhooks}. + * * @param companyId - Company ID * @returns List of webhooks * @@ -47,7 +62,10 @@ export class WebhooksResource { /** * Create a new webhook subscription - * + * + * @deprecated A rota `/v1/companies/{id}/webhooks` retorna 404 na API atual + * (confirmado em duas contas, 2026-07-02). Use {@link createAccountWebhook}. + * * @param companyId - Company ID * @param data - Webhook configuration * @returns Created webhook @@ -73,7 +91,10 @@ export class WebhooksResource { /** * Retrieve a specific webhook - * + * + * @deprecated A rota `/v1/companies/{id}/webhooks` retorna 404 na API atual + * (confirmado em duas contas, 2026-07-02). Use {@link retrieveAccountWebhook}. + * * @param companyId - Company ID * @param webhookId - Webhook ID * @returns Webhook details @@ -96,7 +117,10 @@ export class WebhooksResource { /** * Update a webhook - * + * + * @deprecated A rota `/v1/companies/{id}/webhooks` retorna 404 na API atual + * (confirmado em duas contas, 2026-07-02). Use {@link updateAccountWebhook}. + * * @param companyId - Company ID * @param webhookId - Webhook ID * @param data - Data to update @@ -124,7 +148,10 @@ export class WebhooksResource { /** * Delete a webhook - * + * + * @deprecated A rota `/v1/companies/{id}/webhooks` retorna 404 na API atual + * (confirmado em duas contas, 2026-07-02). Use {@link deleteAccountWebhook}. + * * @param companyId - Company ID * @param webhookId - Webhook ID * @@ -219,9 +246,12 @@ export class WebhooksResource { /** * Test webhook delivery - * + * * Sends a test event to the webhook URL to verify it's working - * + * + * @deprecated A rota `/v1/companies/{id}/webhooks` retorna 404 na API atual + * (confirmado em duas contas, 2026-07-02). Use {@link pingAccountWebhook}. + * * @param companyId - Company ID * @param webhookId - Webhook ID * @returns Test result @@ -250,35 +280,85 @@ export class WebhooksResource { // -------------------------------------------------------------------------- // Account-scoped operations (/v2/webhooks) — NOT company-scoped. // These take no companyId; they manage webhooks at the account level. + // + // Wire contract (specs oficiais + confirmado ao vivo em 2026-07-02): + // - create/update REQUESTS must be wrapped in a `webHook` envelope — the API + // rejects a bare body with 400 "missing required properties: 'webHook'". + // - Single-object RESPONSES come wrapped as { webHook: {...} } and are + // unwrapped here (with a defensive raw-body fallback). // -------------------------------------------------------------------------- /** * List account-level webhooks (`GET /v2/webhooks`). * * The API wraps the result as `{ webHooks: [...] }`; this normalizes it to the - * SDK's `ListResponse` (`{ data: [...] }`). + * SDK's `ListResponse` (`{ data: [...] }`). */ - async listAccountWebhooks(): Promise> { - const response = await this.account.get<{ webHooks?: Webhook[] }>('/webhooks'); + async listAccountWebhooks(): Promise> { + const response = await this.account.get<{ webHooks?: AccountWebhook[] }>('/webhooks'); return { data: response.data?.webHooks ?? [] }; } - /** Create an account-level webhook (`POST /v2/webhooks`). */ - async createAccountWebhook(data: Partial): Promise { - const response = await this.account.post('/webhooks', data); - return response.data; + /** + * Create an account-level webhook (`POST /v2/webhooks`). + * + * NFE.io **verifies the target URI at creation time**: it sends a test request + * (ping) to `data.uri` and the endpoint must already be live and answer 2xx, + * otherwise creation fails. The `secret` must be 32–64 characters; it is echoed + * back in the create response but omitted on subsequent reads. + * + * @example + * ```typescript + * const webhook = await nfe.webhooks.createAccountWebhook({ + * uri: 'https://seu-site.com/webhook/nfe', // precisa responder 2xx já na criação + * contentType: 'json', + * secret: 'um-segredo-de-32-a-64-caracteres-aqui', + * filters: ['service_invoice.issued_successfully', 'service_invoice.cancelled_successfully'], + * }); + * console.log('Webhook criado:', webhook.id); + * ``` + */ + async createAccountWebhook(data: AccountWebhook): Promise { + const response = await this.account.post<{ webHook?: AccountWebhook }>('/webhooks', { + webHook: data, + }); + return response.data?.webHook ?? (response.data as AccountWebhook); } /** Retrieve an account-level webhook by id (`GET /v2/webhooks/{id}`). */ - async retrieveAccountWebhook(webhookId: ResourceId): Promise { - const response = await this.account.get(`/webhooks/${webhookId}`); - return response.data; + async retrieveAccountWebhook(webhookId: ResourceId): Promise { + const response = await this.account.get<{ webHook?: AccountWebhook }>( + `/webhooks/${webhookId}` + ); + return response.data?.webHook ?? (response.data as AccountWebhook); } - /** Update an account-level webhook by id (`PUT /v2/webhooks/{id}`). */ - async updateAccountWebhook(webhookId: ResourceId, data: Partial): Promise { - const response = await this.account.put(`/webhooks/${webhookId}`, data); - return response.data; + /** + * Update an account-level webhook by id (`PUT /v2/webhooks/{id}`). + * + * ⚠️ O `PUT` tem **semântica de substituição integral** (confirmado ao vivo em + * 2026-07-03): campos omitidos voltam ao padrão — em particular, um update sem + * `status` **desativa o webhook** (`status` volta a `"Inactive"`). Envie o + * objeto completo, por exemplo partindo de {@link retrieveAccountWebhook}: + * + * @example + * ```typescript + * const current = await nfe.webhooks.retrieveAccountWebhook(id); + * await nfe.webhooks.updateAccountWebhook(id, { + * ...current, + * filters: [...(current.filters ?? []), 'service_invoice.cancelled_successfully'], + * }); + * ``` + */ + async updateAccountWebhook( + webhookId: ResourceId, + data: Partial + ): Promise { + const response = await this.account.put<{ webHook?: AccountWebhook }>( + `/webhooks/${webhookId}`, + { webHook: data } + ); + return response.data?.webHook ?? (response.data as AccountWebhook); } /** Delete a single account-level webhook by id (`DELETE /v2/webhooks/{id}`). */ @@ -305,11 +385,11 @@ export class WebhooksResource { * Fetch the live list of available webhook event types (`GET /v2/webhooks/eventTypes`). * * Prefer this over {@link getAvailableEvents}: the server is the source of truth, - * so new event types are picked up automatically. The return is an **open** union - * (`WebhookEvent | (string & {})`) so new server-side events don't break typing. + * so new event types are picked up automatically. The return is the **open** union + * {@link WebhookEventType}, so new server-side events don't break typing. * The API wraps the result as `{ eventTypes: [{ id, ... }] }`; this extracts the ids. */ - async fetchEventTypes(): Promise> { + async fetchEventTypes(): Promise { const response = await this.account.get<{ eventTypes?: Array<{ id: string }> }>( '/webhooks/eventTypes' ); diff --git a/src/core/types.ts b/src/core/types.ts index eb38789..5405dd6 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -166,6 +166,12 @@ export type SpecialTaxRegime = 'Automatico' | 'Nenhum' | 'MicroempresaMunicipal' // Webhook Types // ============================================================================ +/** + * @deprecated Este shape (`url`/`events`/`active`) não corresponde ao contrato real da + * API de webhooks (confirmado ao vivo em 2026-07-02: a API rejeita `url` com + * `400 "The Uri field is required"`). Use {@link AccountWebhook} com os métodos + * account-scoped (`listAccountWebhooks`, `createAccountWebhook`, ...). + */ export interface Webhook { /** Webhook ID */ id?: string; @@ -183,8 +189,106 @@ export interface Webhook { modifiedOn?: string; } +/** + * @deprecated Estes literais (`invoice.*`) não existem na API real — os event types + * vivos seguem o padrão `service_invoice.issued_successfully` etc. Use + * {@link WebhookEventType} (lista viva via `webhooks.fetchEventTypes()`). + */ export type WebhookEvent = 'invoice.created' | 'invoice.issued' | 'invoice.cancelled' | 'invoice.failed'; +/** + * Webhook de conta — shape real do recurso em `/v2/webhooks`, conforme os specs + * oficiais (`openapi/spec/nf-servico-v1.yaml` e equivalentes) e confirmado ao vivo + * (2026-07-02). + * + * Nota de contrato: o spec declara `contentType`/`status` como enums inteiros, mas a + * API serializa strings (`"json"`, `"Active"`) — o tipo segue o formato de fio real. + */ +export interface AccountWebhook { + /** ID exclusivo do webhook (GUID gerado pela API) */ + id?: string; + /** URL de entrega das notificações. Verificada com ping na criação (exige 2xx). */ + uri: string; + /** Media type das entregas (a API serializa string, ex.: `"json"`) */ + contentType?: 'json' | (string & {}); + /** + * Segredo de 32–64 caracteres usado no HMAC-SHA1 do header `X-Hub-Signature`. + * Ecoado na resposta do create; omitido em list/retrieve (write-only na leitura). + */ + secret?: string; + /** Filtros de event types (ver {@link WebhookEventType} e `fetchEventTypes()`) */ + filters?: Array; + /** Pular verificação do certificado SSL do host da URI (padrão: `false`) */ + insecureSsl?: boolean; + /** Cabeçalhos HTTP adicionais enviados nas entregas */ + headers?: Record; + /** Propriedades adicionais incluídas no corpo das notificações */ + properties?: Record; + /** Status do webhook (a API serializa string, ex.: `"Active"`) */ + status?: 'Active' | (string & {}); + /** Data de criação */ + createdOn?: string; + /** Data de modificação */ + modifiedOn?: string; +} + +/** + * Event types reais de webhook, extraídos de `GET /v2/webhooks/eventTypes` ao vivo + * (2026-07-02). União aberta: ids novos do servidor continuam aceitos sem quebra. + * Prefira `webhooks.fetchEventTypes()` para a lista viva. + * + * (O id `legal_entity_taxpayer:updated_sucessfully` — com `:` e grafia `sucessfully` — + * é reproduzido exatamente como a API o retorna.) + */ +export type WebhookEventType = + | 'service_invoice.issued' + | 'service_invoice.issued_successfully' + | 'service_invoice.issued_error' + | 'service_invoice.issued_failed' + | 'service_invoice.cancelled' + | 'service_invoice.cancelled_successfully' + | 'service_invoice.cancelled_error' + | 'service_invoice.cancelled_failed' + | 'service_invoice.pulled' + | 'service_invoice_inbound.issued_successfully' + | 'service_invoice_inbound.event_raised_successfully' + | 'product_invoice.issued_successfully' + | 'product_invoice.issued_error' + | 'product_invoice.issued_failed' + | 'product_invoice.cancelled_successfully' + | 'product_invoice.cancelled_error' + | 'product_invoice.cancelled_failed' + | 'product_invoice.cce_successfully' + | 'product_invoice.cce_error' + | 'product_invoice.cce_failed' + | 'product_invoice.dfe_event_successfully' + | 'product_invoice.dfe_event_error' + | 'product_invoice.dfe_event_failed' + | 'product_invoice.disabled_successfully' + | 'product_invoice.disabled_error' + | 'product_invoice.disabled_failed' + | 'product_invoice_inbound.issued_successfully' + | 'product_invoice_inbound.event_raised_successfully' + | 'product_invoice_inbound.input_event_raised_successfully' + | 'product_invoice_inbound_summary.issued_successfully' + | 'product_invoice_inbound_summary.event_raised_successfully' + | 'consumer_invoice.issued_successfully' + | 'consumer_invoice.issued_error' + | 'consumer_invoice.issued_failed' + | 'consumer_invoice.cancelled_successfully' + | 'consumer_invoice.cancelled_error' + | 'consumer_invoice.cancelled_failed' + | 'transportation_invoice_inbound.issued_successfully' + | 'transportation_invoice_inbound.event_raised_successfully' + | 'legal_entity_taxpayer:updated_sucessfully' + | 'product_tax.created_successfully' + | 'product_tax.creation_failed' + | 'product_tax.custom_rules_requested' + | 'tax_payment_form.created_successfully' + | 'tax_payment_form.creation_failed' + | 'tax_payment_form.creation_not_needed' + | (string & {}); + // ============================================================================ // Address Types (for Address Lookup API) // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 8a14967..d8d6a47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,7 +57,7 @@ export { NfeClient, createNfeClient, VERSION, SUPPORTED_NODE_VERSIONS, CTE_API_B * @see {@link ServiceInvoice} - Service invoice entity type * @see {@link LegalPerson} - Legal person (empresa) entity type * @see {@link NaturalPerson} - Natural person (pessoa física) entity type - * @see {@link Webhook} - Webhook configuration type + * @see {@link AccountWebhook} - Webhook configuration type (account-scoped, `/v2/webhooks`) */ export type { // Configuration @@ -74,6 +74,8 @@ export type { ServiceInvoiceDetails, Webhook, WebhookEvent, + AccountWebhook, + WebhookEventType, // Address types Address, diff --git a/tests/types/account-webhook-alignment.test-d.ts b/tests/types/account-webhook-alignment.test-d.ts new file mode 100644 index 0000000..63a713b --- /dev/null +++ b/tests/types/account-webhook-alignment.test-d.ts @@ -0,0 +1,75 @@ +/** + * Alignment guard: o tipo manuscrito `AccountWebhook` amarrado ao schema GERADO + * do spec oficial (`src/generated/nf-servico-v1.ts`, paths `/v2/webhooks`). + * + * Se um sync de spec mudar o contrato de webhooks, este arquivo quebra o + * `npm run test:types` em vez de deixar o tipo manual driftar em silêncio — + * a causa raiz do bug original (contrato company-scoped alucinado no rewrite) + * foi exatamente o resource manuscrito ignorar estas fontes. + * + * Desvios DELIBERADOS do gerado (contrato de fio real, sonda 2026-07-02): + * - `contentType`/`status`: o spec declara enums inteiros (0 | 1), mas a API + * serializa strings ("json", "Active"). O AccountWebhook segue o fio. + * Divergência reportada ao time de docs (tasks.md 3.3). As assertions abaixo + * PINAM o enum int do gerado: se o spec for corrigido para string, elas + * falham — sinal para remover o desvio daqui e do types.ts. + * - `id`: ausente no body de create do gerado (a API que o atribui); presente + * no AccountWebhook porque o mesmo tipo descreve as respostas. + */ + +import { describe, it, expectTypeOf } from 'vitest'; +import type { paths } from '../../src/generated/nf-servico-v1.js'; +import type { AccountWebhook } from '../../src/index.js'; + +// Schema gerado do body de criação (POST /v2/webhooks -> requestBody -> webHook) +type GeneratedCreateEnvelope = NonNullable< + paths['/v2/webhooks']['post']['requestBody'] +>['content']['application/json']; +type GeneratedWebHook = NonNullable; + +describe('AccountWebhook ↔ schema gerado de /v2/webhooks', () => { + it('o request de create é envelopado na chave webHook (contrato ao vivo: 400 sem ela)', () => { + expectTypeOf().toEqualTypeOf<'webHook'>(); + }); + + it('todo campo do gerado existe no AccountWebhook (spec sync novo → campo novo → falha aqui)', () => { + expectTypeOf().toExtend(); + }); + + it('AccountWebhook não inventa campos: só o gerado + id (respostas)', () => { + expectTypeOf>().toEqualTypeOf<'id'>(); + }); + + it('campos de contrato idêntico ao gerado', () => { + const g = {} as GeneratedWebHook; + expectTypeOf(g.uri).toEqualTypeOf(); + expectTypeOf(g.secret).toEqualTypeOf(); + expectTypeOf(g.insecureSsl).toEqualTypeOf(); + expectTypeOf(g.createdOn).toEqualTypeOf(); + expectTypeOf(g.modifiedOn).toEqualTypeOf(); + const a = {} as AccountWebhook; + expectTypeOf(a.uri).toEqualTypeOf(); + expectTypeOf(a.secret).toEqualTypeOf(); + expectTypeOf(a.insecureSsl).toEqualTypeOf(); + }); + + it('DESVIO PINADO: spec declara contentType/status como enum int; o fio real é string', () => { + // Se estas duas falharem, o spec foi corrigido → remover o desvio do AccountWebhook. + expectTypeOf().toEqualTypeOf<0 | 1 | undefined>(); + expectTypeOf().toEqualTypeOf<0 | 1 | undefined>(); + // O tipo público segue o formato de fio (sonda 2026-07-02: "json" / "Active"). + const a = {} as AccountWebhook; + expectTypeOf(a.contentType).toExtend(); + expectTypeOf(a.status).toExtend(); + }); + + it('filters aceita os event types reais (união aberta sobre o string[] do gerado)', () => { + const g = {} as GeneratedWebHook; + expectTypeOf(g.filters).toEqualTypeOf(); + const ok: AccountWebhook = { + uri: 'https://example.com/hook', + filters: ['service_invoice.issued_successfully', 'um_evento_futuro.qualquer'], + }; + expectTypeOf(ok.filters).not.toBeNever(); + }); +}); diff --git a/tests/unit/webhooks.test.ts b/tests/unit/webhooks.test.ts index 1a67c45..978daa0 100644 --- a/tests/unit/webhooks.test.ts +++ b/tests/unit/webhooks.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WebhooksResource } from '../../src/core/resources/webhooks'; import type { HttpClient } from '../../src/core/http/client'; -import type { HttpResponse, ListResponse, Webhook, WebhookEvent } from '../../src/core/types'; +import type { + AccountWebhook, + HttpResponse, + ListResponse, + Webhook, + WebhookEvent, +} from '../../src/core/types'; import { TEST_COMPANY_ID, TEST_WEBHOOK_ID } from '../setup'; describe('WebhooksResource', () => { @@ -178,31 +184,86 @@ describe('WebhooksResource', () => { expect(result.data[0]?.id).toBe('w1'); }); - it('createAccountWebhook POSTs /webhooks', async () => { + // Fixture do 201 real da sonda ao vivo (2026-07-02): resposta envelopada em + // { webHook } e secret ecoado na criação. Sem o envelope no REQUEST a API + // responde 400 "missing required properties including: 'webHook'". + const LIVE_CREATED: AccountWebhook = { + id: '948ee1f570934e768805c199d70e2e86', + uri: 'https://httpbin.org/status/200', + secret: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + contentType: 'json', + insecureSsl: false, + status: 'Active', + filters: ['service_invoice.issued_successfully'], + createdOn: '2026-07-03T02:41:53.5466401+00:00', + modifiedOn: '2026-07-03T02:41:53.546678+00:00', + }; + + it('createAccountWebhook wraps the request in a {webHook} envelope and unwraps the response', async () => { vi.mocked(mockHttpClient.post).mockResolvedValue({ - data: { id: 'w1' }, status: 201, headers: {}, - } as HttpResponse); + data: { webHook: LIVE_CREATED }, status: 201, headers: {}, + } as HttpResponse<{ webHook: AccountWebhook }>); + + const input: AccountWebhook = { + uri: 'https://httpbin.org/status/200', + contentType: 'json', + secret: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + filters: ['service_invoice.issued_successfully'], + }; + const created = await webhooks.createAccountWebhook(input); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/webhooks', { webHook: input }); + expect(created.id).toBe('948ee1f570934e768805c199d70e2e86'); + expect(created.status).toBe('Active'); + expect(created.secret).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + it('createAccountWebhook falls back to the raw body when the response has no envelope', async () => { + vi.mocked(mockHttpClient.post).mockResolvedValue({ + data: { id: 'w-raw', uri: 'https://x.test/hook' }, status: 201, headers: {}, + } as HttpResponse); - await webhooks.createAccountWebhook({ url: 'https://x.test/hook' }); - expect(mockHttpClient.post).toHaveBeenCalledWith('/webhooks', { url: 'https://x.test/hook' }); + const created = await webhooks.createAccountWebhook({ uri: 'https://x.test/hook' }); + expect(created.id).toBe('w-raw'); }); - it('retrieve/update/delete by id hit /webhooks/{id}', async () => { - vi.mocked(mockHttpClient.get).mockResolvedValue({ data: { id: 'w1' }, status: 200, headers: {} } as HttpResponse); - vi.mocked(mockHttpClient.put).mockResolvedValue({ data: { id: 'w1' }, status: 200, headers: {} } as HttpResponse); + it('retrieve/update unwrap the {webHook} envelope; update wraps its request', async () => { + vi.mocked(mockHttpClient.get).mockResolvedValue({ + data: { webHook: { id: 'w1', uri: 'https://x.test/hook' } }, status: 200, headers: {}, + } as HttpResponse<{ webHook: AccountWebhook }>); + vi.mocked(mockHttpClient.put).mockResolvedValue({ + data: { webHook: { id: 'w1', uri: 'https://x.test/hook', insecureSsl: true } }, + status: 200, headers: {}, + } as HttpResponse<{ webHook: AccountWebhook }>); vi.mocked(mockHttpClient.delete).mockResolvedValue({ data: undefined, status: 204, headers: {} } as HttpResponse); - await webhooks.retrieveAccountWebhook('w1'); - await webhooks.updateAccountWebhook('w1', { active: false }); + const got = await webhooks.retrieveAccountWebhook('w1'); + const updated = await webhooks.updateAccountWebhook('w1', { insecureSsl: true }); await webhooks.deleteAccountWebhook('w1'); await webhooks.pingAccountWebhook('w1'); + expect(got.id).toBe('w1'); + expect(updated.insecureSsl).toBe(true); expect(mockHttpClient.get).toHaveBeenCalledWith('/webhooks/w1'); - expect(mockHttpClient.put).toHaveBeenCalledWith('/webhooks/w1', { active: false }); + expect(mockHttpClient.put).toHaveBeenCalledWith('/webhooks/w1', { + webHook: { insecureSsl: true }, + }); expect(mockHttpClient.delete).toHaveBeenCalledWith('/webhooks/w1'); expect(mockHttpClient.put).toHaveBeenCalledWith('/webhooks/w1/pings', {}); }); + it('retrieve/update fall back to the raw body when the response has no envelope', async () => { + vi.mocked(mockHttpClient.get).mockResolvedValue({ + data: { id: 'w-raw', uri: 'https://x.test/hook' }, status: 200, headers: {}, + } as HttpResponse); + vi.mocked(mockHttpClient.put).mockResolvedValue({ + data: { id: 'w-raw', uri: 'https://x.test/hook' }, status: 200, headers: {}, + } as HttpResponse); + + expect((await webhooks.retrieveAccountWebhook('w-raw')).id).toBe('w-raw'); + expect((await webhooks.updateAccountWebhook('w-raw', {})).id).toBe('w-raw'); + }); + it('deleteAllAccountWebhooks is a distinct method hitting DELETE /webhooks (no id)', async () => { vi.mocked(mockHttpClient.delete).mockResolvedValue({ data: undefined, status: 204, headers: {} } as HttpResponse); From 2af2c8807a50bb63c7e123a6cd11c349560f5072 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 3 Jul 2026 12:30:25 -0300 Subject: [PATCH 2/6] chore(.gitignore): add client-php to ignored files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 27a23af..19085d6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ jspm_packages/ openspec/ nfeio-docs client-ruby +client-php # ---------------------------------------------------------------------------- # Build Outputs # ---------------------------------------------------------------------------- From cccdfe64a36c10a804814044a2324b3a87ed7db0 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 3 Jul 2026 12:33:39 -0300 Subject: [PATCH 3/6] chore: update gitignore --- .gitignore | 2 +- .../design.md | 261 ------------------ .../proposal.md | 201 -------------- .../webhook-signature-verification/spec.md | 144 ---------- .../tasks.md | 46 --- 5 files changed, 1 insertion(+), 653 deletions(-) delete mode 100644 openspec/changes/fix-webhook-signature-verification/design.md delete mode 100644 openspec/changes/fix-webhook-signature-verification/proposal.md delete mode 100644 openspec/changes/fix-webhook-signature-verification/specs/webhook-signature-verification/spec.md delete mode 100644 openspec/changes/fix-webhook-signature-verification/tasks.md diff --git a/.gitignore b/.gitignore index 19085d6..b369ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ client-python node_modules/ bower_components/ jspm_packages/ -openspec/ + nfeio-docs client-ruby client-php diff --git a/openspec/changes/fix-webhook-signature-verification/design.md b/openspec/changes/fix-webhook-signature-verification/design.md deleted file mode 100644 index aa79b28..0000000 --- a/openspec/changes/fix-webhook-signature-verification/design.md +++ /dev/null @@ -1,261 +0,0 @@ -# Design: Fix Webhook Signature Verification - -**Change ID**: `fix-webhook-signature-verification` -**Status**: Draft - ---- - -## Current State Analysis - -### Defective implementation - -[src/core/resources/webhooks.ts:164-189](../../../src/core/resources/webhooks.ts#L164-L189): - -```typescript -validateSignature(payload: string, signature: string, secret: string): boolean { - try { - // Import crypto dynamically to avoid issues in non-Node environments - const crypto = (globalThis as any).require?.('crypto'); - if (!crypto) { - throw new Error('crypto module not available'); - } - - const hmac = crypto.createHmac('sha256', secret); // ❌ wrong algorithm - hmac.update(payload); - const expectedSignature = hmac.digest('hex'); // ❌ lowercase, no prefix handling - - return crypto.timingSafeEqual( // ❌ throws on length mismatch - Buffer.from(signature), - Buffer.from(expectedSignature) - ); - } catch (error) { - console.error('Error validating webhook signature:', error); // ❌ silent failure - return false; - } -} -``` - -**Per-defect breakdown:** - -| # | Line | Symptom | Root cause | -|---|---|---|---| -| 1 | 171 | `crypto` is always `undefined` → throws | `globalThis.require` is non-standard. Not present in ESM, not present in CJS module scope, only present (sometimes) at REPL or via legacy globals | -| 2 | 176 | Even if (1) is fixed, signature never matches | NFE.io uses HMAC-SHA1, not SHA256 | -| 3 | 178 | Even if (1)+(2) fixed, comparison fails | NFE.io returns uppercase hex; our `digest('hex')` returns lowercase | -| 4 | 181-184 | Even if (1)+(2)+(3) fixed, still fails | NFE.io sends `sha1=`, not bare `` | -| 5 | 181 | Throws on length mismatch | `timingSafeEqual` requires equal lengths; with prefix mismatch (5 extra chars), lengths differ → throws → caught → returns false but error path is opaque | -| 6 | 187 | Bug masking | Wrapping everything in `try/catch + console.error + return false` hides real errors from operators | - -### Documentation inconsistency - -The header name is wrong in **three** places that ship to users: - -| Location | Current | Should be | -|---|---|---| -| [webhooks.ts:139](../../../src/core/resources/webhooks.ts#L139) (JSDoc) | `X-NFE-Signature` | `X-Hub-Signature` | -| [webhooks.ts:147](../../../src/core/resources/webhooks.ts#L147) (JSDoc example) | `x-nfe-signature` | `x-hub-signature` | -| [docs/API.md:1483](../../../docs/API.md#L1483) | `x-nfe-signature` | `x-hub-signature` | -| [examples/all-resources-demo.js:160](../../../examples/all-resources-demo.js#L160) | `x-nfe-signature` | `x-hub-signature` | -| [examples/real-world-webhooks.js:143](../../../examples/real-world-webhooks.js#L143) | `X-NFE-Signature` | `X-Hub-Signature` | - -Already correct (no change needed): -- [skills/nfeio-sdk/SKILL.md:284](../../../skills/nfeio-sdk/SKILL.md#L284) -- [skills/nfeio-sdk/references/service-invoices-and-polling.md:317-321](../../../skills/nfeio-sdk/references/service-invoices-and-polling.md#L317-L321) - -The skill files were apparently authored against the real API while the SDK was authored against an assumed contract — they diverged. - ---- - -## Target Design - -### Module-level imports - -Move from runtime `require` to top-of-file static import. This is the v3 SDK norm everywhere else: - -```typescript -import { createHmac, timingSafeEqual } from 'node:crypto'; -``` - -This makes the dependency static, tree-shakable, and type-checked. `node:` prefix is the modern Node.js convention and works on Node 18+. - -### Implementation - -```typescript -validateSignature( - payload: Buffer | string, - signature: string | string[] | undefined, - secret: string, -): boolean { - // Guard: required inputs - if (!secret || signature == null) return false; - - // Normalize header value: Node's IncomingMessage typing allows string | string[] - const sigStr = Array.isArray(signature) ? signature[0] : signature; - if (typeof sigStr !== 'string' || sigStr.length === 0) return false; - - // Strip and validate prefix - const PREFIX = 'sha1='; - if (sigStr.length <= PREFIX.length) return false; - if (sigStr.slice(0, PREFIX.length).toLowerCase() !== PREFIX) return false; - - // Normalize case + validate hex shape (HMAC-SHA1 is always 40 hex chars) - const received = sigStr.slice(PREFIX.length).toLowerCase(); - if (!/^[a-f0-9]{40}$/.test(received)) return false; - - // Compute expected over raw body bytes - const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8'); - const expected = createHmac('sha1', secret).update(body).digest('hex'); - - // Length-checked timing-safe compare on 20-byte digests - const a = Buffer.from(received, 'hex'); - const b = Buffer.from(expected, 'hex'); - // Both are 20 bytes here, but assert anyway — defense in depth - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); -} -``` - -### Why `Buffer | string` for payload - -NFE.io computes HMAC over **raw body bytes**. If the user passes a string, we MUST agree on encoding. UTF-8 is correct because: -- The Content-Type header sent by NFE.io is `application/json; charset=utf-8` (confirmed via probe) -- JSON is UTF-8 by spec -- `JSON.stringify(obj)` returns a UTF-8-encodable string - -However, several footguns exist if the user reaches for `JSON.stringify(req.body)`: -- Property iteration order is implementation-defined → byte order may differ from what NFE.io signed -- Whitespace, escape sequences (`/` vs `\/`), Unicode escapes can differ -- Date serialization, BigInt, etc. - -**The only safe path is to validate against the bytes received on the wire.** We document this prominently and provide the `Buffer` path first-class. The string path is a convenience for users who *know* they have the exact original body string. - -### Length-check before `timingSafeEqual` - -After the regex `^[a-f0-9]{40}$` and the SHA-1 output (always 40 hex), both buffers are guaranteed 20 bytes. The explicit `if (a.length !== b.length)` is defense-in-depth that costs one comparison and prevents a class of throw-on-bad-input bugs. - -### Why no `try/catch` - -Every error path in the new implementation is handled by an explicit `return false` on validated branches. There is no remaining throw site: -- Regex `.test()` doesn't throw -- `Buffer.from(hex, 'hex')` doesn't throw on invalid hex (returns shorter buffer) — but we already validated via regex -- `createHmac` only throws on unknown algorithm; `'sha1'` is hardcoded -- `timingSafeEqual` only throws on length mismatch; we pre-check - -So no try/catch is needed, and removing it means real bugs surface as their natural exceptions instead of being swallowed. - ---- - -## Optional ergonomic helper (Phase 4) - -Many users have request objects with both header and body. Provide a thin convenience: - -```typescript -verifyRequest( - request: { - headers: Record; - body: Buffer | string; - }, - secret: string, -): boolean { - const sig = - request.headers['x-hub-signature'] ?? - request.headers['X-Hub-Signature']; - return this.validateSignature(request.body, sig, secret); -} -``` - -This composes on top of `validateSignature` and stays out of the validation logic itself, so it can be added (or removed) without touching the security-critical path. - ---- - -## Test Strategy - -### Live fixtures - -Capture three (body, secret, signature) triplets directly from a probe run and commit them to `tests/fixtures/webhook-signatures.json`. These are the ground truth — if production NFE.io changes scheme, these fixtures break and we know immediately. - -Example fixture row: -```json -{ - "label": "product_invoice ping (api.nfse.io)", - "secret": "probe-secret-32chars-minimum-length-1", - "body": "{\"action\":\"ping\",\"webHook\":{\"Id\":\"907ce731f350454e95ca8e4963ab9656\",...}}", - "header_value": "sha1=52854606A8B839F24B818CA0DF33CC5A5C7C5406" -} -``` - -### Test matrix - -| Category | Cases | -|---|---| -| Positive — fixtures | Each captured triplet validates to `true` | -| Positive — round-trip | For random bodies + secrets, computed signature validates to `true` | -| Positive — case | Same signature in UPPER and lower case both validate | -| Positive — payload | Same body as `Buffer` and `string` both validate | -| Positive — header shape | Header as `string` and `[string]` both validate | -| Negative — tamper | Flip one byte in body → `false` | -| Negative — wrong secret | Right body, wrong secret → `false` | -| Negative — wrong prefix | `sha256=...` → `false` | -| Negative — no prefix | bare hex → `false` | -| Negative — wrong length | `sha1=abc` → `false` | -| Negative — non-hex | `sha1=zzz...` → `false` | -| Negative — undefined inputs | missing secret, missing header → `false` | -| Robustness | Each negative case asserts `expect(...).toBe(false)` AND `expect(() => ...).not.toThrow()` | - -### Coverage target - -`validateSignature` is small and security-critical. Target **100% branch coverage** for this single function. Aggregate webhook resource coverage should remain at or above the project's 80% threshold. - ---- - -## Migration - -### For SDK users - -Before: -```js -const sig = req.headers['x-nfe-signature']; // wrong header -const payload = JSON.stringify(req.body); // wrong bytes -nfe.webhooks.validateSignature(payload, sig, secret); // always false -``` - -After: -```js -// Use express.raw() so req.body is a Buffer with the EXACT bytes NFE.io signed -app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => { - const ok = nfe.webhooks.validateSignature( - req.body, // Buffer — preserves bytes - req.headers['x-hub-signature'], // correct header - process.env.NFE_WEBHOOK_SECRET, - ); - if (!ok) return res.status(401).end(); - const payload = JSON.parse(req.body.toString('utf8')); - // process payload... -}); -``` - -### Internal callers - -There are none — the SDK does not invoke `validateSignature` internally. All call sites are user code. - -### Release vehicle - -This is a bugfix → patch release. Add a prominent ⚠ note in `CHANGELOG.md` (Portuguese, per project convention): - -> ### 🔒 Correção crítica: validação de assinatura de webhook -> Versões anteriores rejeitavam silenciosamente toda assinatura legítima da NFE.io. Atualize imediatamente. Veja a seção "Webhooks" do README para o exemplo correto. - ---- - -## Decision Log - -| # | Decision | Alternatives considered | Rationale | -|---|---|---|---| -| 1 | Use static `import { createHmac } from 'node:crypto'` | Dynamic `require`, lazy import | Project is Node 18+ only; static imports are idiomatic everywhere else in v3; eliminates the runtime-availability class of bugs | -| 2 | Accept `Buffer \| string` for payload, not just string | string-only, Buffer-only | Buffer is the correct, byte-exact form. String is a footgun but common — accept it with UTF-8 encoding and document the risk | -| 3 | Return `false` on every error path, not throw | Throw on programmer errors (missing secret) | Webhook validation runs on every incoming request. Throwing here causes 500s where 401 is correct. Predictable boolean simplifies caller code | -| 4 | No try/catch wrapper | Keep try/catch as safety net | Every throw site has been eliminated. A bare try/catch hides real bugs and was the source of defect #5 silently masking defect #1 | -| 5 | Compare bytes via `timingSafeEqual` on raw hex-decoded buffers, not strings | String compare lowercased hex | timing-safety is the whole point of `timingSafeEqual`. Comparing 20-byte buffers is the strongest guarantee. Length is pre-validated | -| 6 | Validate hex shape with regex before `Buffer.from` | Skip validation | `Buffer.from('zz', 'hex')` returns an empty buffer silently — would otherwise pass through to a length-mismatch failure. Better to reject explicitly at the prefix layer | -| 7 | Lowercase normalization on receive side, not just the expected side | Compare uppercase to uppercase | Defense-in-depth against potential future NFE.io changes to case. Cost is one `.toLowerCase()` on a ≤45-char string | -| 8 | Don't support `sha256=` prefix yet | Add `sha256=` branch defensively | YAGNI. Adding it later is additive (one extra branch + algorithm switch). Real risk is wrong-by-default behavior, not future flexibility | diff --git a/openspec/changes/fix-webhook-signature-verification/proposal.md b/openspec/changes/fix-webhook-signature-verification/proposal.md deleted file mode 100644 index cdf6343..0000000 --- a/openspec/changes/fix-webhook-signature-verification/proposal.md +++ /dev/null @@ -1,201 +0,0 @@ -# Proposal: Fix Webhook Signature Verification - -**Change ID**: `fix-webhook-signature-verification` -**Status**: Draft -**Created**: 2026-06-11 -**Author**: Andre Kutianski (with empirical evidence collected via live probe) -**Priority**: 🔴 CRITICAL (security-relevant; method is currently non-functional) - ---- - -## Problem Statement - -`WebhooksResource.validateSignature()` ([src/core/resources/webhooks.ts:164](../../../src/core/resources/webhooks.ts#L164)) is the SDK's gatekeeper against forged webhook deliveries. **It does not work today.** Empirical probing against the real NFE.io API revealed five independent defects, any one of which causes valid webhooks to be rejected: - -| # | Defect | Current code | NFE.io reality | -|---|---|---|---| -| 1 | **Wrong header name** documented & in examples | `X-NFE-Signature` | `X-Hub-Signature` | -| 2 | **Wrong hash algorithm** | `HMAC-SHA256` | `HMAC-SHA1` | -| 3 | **Wrong case comparison** | implicit lowercase hex | UPPERCASE hex | -| 4 | **No prefix handling** | raw hex compare | `sha1=` prefix sent | -| 5 | **`require('crypto')` is broken** | `(globalThis as any).require?.('crypto')` | always returns `undefined` in ESM and CommonJS modules → `validateSignature` always returns `false` | - -Defects #1–#4 mean that **even after fixing #5**, the method would still reject all real NFE.io webhooks. Defect #5 means the method is currently a no-op that silently rejects everything, including legitimate traffic. - -Two minor implementation bugs compound the issue: -- `crypto.timingSafeEqual()` throws (not returns false) when the two buffers differ in length — easy attacker DoS or noisy log spam. -- Errors are swallowed by `console.error` and the method returns `false`, hiding configuration bugs. - ---- - -## Evidence - -A live probe was run against `https://api.nfse.io/v2/webhooks` in the test account, with three independent registrations (filters: product invoices, service invoices, and a mixed cross-product filter). All three produced **identical signature schemes**: - -``` -Header: X-Hub-Signature -Format: sha1= -Example: sha1=BCD17C02B9E3B40A18E745E7E04247E4AD2DD935 -Verified: Content-MD5 of the body also matched byte-for-byte (independent confirmation) -``` - -The probe also established: -- All NFE.io products (NF-e Produto, NF-e Consumidor, NF-e Serviço, CT-e, Distribuição) share **one global webhook registry** on `api.nfse.io`. Signature scheme is identical across all of them. -- The documentation at `nfeio-docs/.../distribuicao/02-doc-tecnica-clientes-dev.md` claims `X-NFe-Signature` + HMAC-SHA256 — **this is incorrect** (no such scheme exists in production). Fixing the doc is out of scope here but should be tracked. - -Probe script + raw logs preserved at `/tmp/webhook-probe.js` and `/tmp/webhook-probe.log` for reproducibility. Reference test fixtures derived from real responses will be included in the implementation. - ---- - -## Goals - -### Primary - -1. `validateSignature()` MUST correctly verify webhook signatures sent by NFE.io's production API. -2. The method MUST use `node:crypto` via static import (no runtime `require` lookups). -3. The method MUST be timing-safe regardless of input length or format. -4. The method MUST NOT throw on malformed inputs — invalid signatures simply return `false`. -5. Documentation, examples, and the bundled SDK skill MUST reflect the correct header name and algorithm. - -### Secondary - -1. Add a permissive overload that accepts the raw `IncomingMessage`-style request object, so users do not have to manually pull `headers['x-hub-signature']` and reconstruct the raw body. -2. Surface `X-Hook-Id` and `X-Hook-Attempts` headers in a small helper type so consumers can implement idempotency without poking at raw headers. - -### Non-Goals - -1. **Fixing the broader `Webhook` resource shape mismatch** (url vs uri, events vs filters, etc.) — there is a separate, larger discrepancy between the SDK's webhook CRUD types and the real OpenAPI schema. That deserves its own proposal (`align-webhooks-resource-with-openapi`). This change is scoped to signature verification only. -2. **Webhook delivery itself** — the SDK is the receiver, not the deliverer. Out of scope. -3. **Updating `nfeio-docs/`** — third-party doc repo. Will be tracked as a separate issue against the docs team. -4. **Supporting alternative algorithms** (e.g., future SHA256 migration) — YAGNI. Re-open if NFE.io publishes a v2 webhook signature scheme. - ---- - -## Proposed Solution - -### New `validateSignature` API surface - -```typescript -class WebhooksResource { - /** - * Verify the HMAC-SHA1 signature on a webhook delivery from NFE.io. - * - * @param payload - The raw request body. Pass a Buffer when possible to - * preserve exact bytes; strings are encoded as UTF-8. - * @param signature - The full value of the X-Hub-Signature header, - * including the "sha1=" prefix. - * @param secret - The webhook secret configured when registering. - * @returns true if the signature matches; false on any mismatch, malformed - * input, missing prefix, or runtime error. - * - * @example - * app.post('/webhook', express.raw({ type: '*\/*' }), (req, res) => { - * const ok = nfe.webhooks.validateSignature( - * req.body, - * req.headers['x-hub-signature'], - * process.env.NFE_WEBHOOK_SECRET, - * ); - * if (!ok) return res.status(401).end(); - * // ... - * }); - */ - validateSignature( - payload: Buffer | string, - signature: string | string[] | undefined, - secret: string, - ): boolean; -} -``` - -### Implementation outline - -```typescript -import { createHmac, timingSafeEqual } from 'node:crypto'; - -validateSignature(payload, signature, secret) { - if (!secret || !signature) return false; - const sigStr = Array.isArray(signature) ? signature[0] : signature; - if (typeof sigStr !== 'string') return false; - - const prefix = 'sha1='; - if (!sigStr.toLowerCase().startsWith(prefix)) return false; - const received = sigStr.slice(prefix.length).toLowerCase(); - if (!/^[a-f0-9]{40}$/.test(received)) return false; - - const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8'); - const expected = createHmac('sha1', secret).update(body).digest('hex'); // always lowercase - - const a = Buffer.from(received, 'hex'); - const b = Buffer.from(expected, 'hex'); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); -} -``` - -The comparison normalizes both sides to lowercase hex bytes, so NFE.io's UPPERCASE output and our lowercase output collide cleanly. Buffers always have length 20 (HMAC-SHA1) at the comparison step, so `timingSafeEqual` never throws. - -### Breaking change? - -Strictly: yes — the method signature gains `Buffer` as an accepted payload type, and the semantic changes from "always-false" to "correct verification". In practice, **no consumer code can be successfully using `validateSignature` today**, because it always returns `false`. The "breaking" change is therefore "code that depended on the broken behavior will now correctly accept legitimate webhooks." We treat this as a bugfix and ship in the next patch/minor release with a clear CHANGELOG entry. - ---- - -## Implementation Phases - -### Phase 1 — Core fix (Day 1) -- Rewrite `validateSignature` with static `node:crypto` import, prefix handling, case normalization, length-safe comparison -- Update inline JSDoc with the corrected header name and a working Express snippet - -### Phase 2 — Tests (Day 1) -- Unit tests using **real fixtures** captured from the probe (body, secret, signature triplets) -- Property-style tests: every signature produced by `createHmac('sha1', secret)` over a body MUST validate, in both upper and lowercase hex -- Negative tests: missing prefix, wrong algorithm prefix (`sha256=`), wrong length, tampered body, missing secret, missing header, header as array, Buffer vs string payload equivalence -- Anti-regression: assert the method does NOT throw on any malformed input - -### Phase 3 — Documentation propagation (Day 2) -- `docs/API.md` webhook section: header name + algorithm -- `examples/all-resources-demo.js`, `examples/real-world-webhooks.js`: corrected snippets -- `skills/nfeio-sdk/SKILL.md` and `skills/nfeio-sdk/references/service-invoices-and-polling.md`: already correct on header name, but update to show the prefix and case handling -- `CHANGELOG.md`: prominent bugfix entry with migration note - -### Phase 4 — Optional ergonomic helpers (Day 2, can be deferred) -- `verifyRequest(req, secret)` overload accepting `{ headers, body }` shape -- Surface `X-Hook-Id` / `X-Hook-Attempts` via a typed accessor - ---- - -## Success Criteria - -1. ✅ `validateSignature` returns `true` for the three captured probe fixtures (verified in tests) -2. ✅ `validateSignature` returns `false` (never throws) for every adversarial input in the negative test suite -3. ✅ `npm run typecheck && npm run lint && npm test -- --run` all pass -4. ✅ No remaining occurrence of `X-NFE-Signature` in `src/`, `docs/`, `examples/`, or `skills/` -5. ✅ No remaining occurrence of `sha256` or `SHA-256` in the webhook signature context -6. ✅ A new live re-run of the probe still matches the fixtures' algorithm/format (smoke test on each release) - ---- - -## Risks and Mitigations - -| Risk | Impact | Likelihood | Mitigation | -|---|---|---|---| -| NFE.io changes the signature scheme in the future (e.g., adds SHA256) | High | Low | The new code is explicit about the prefix (`sha1=`). Adding `sha256=` later is additive, not breaking | -| User-side raw body parsing strips/normalizes bytes before reaching `validateSignature` | High | Medium | Docs prominently warn: use `express.raw()` or equivalent, not `express.json()`. Accept `Buffer` first-class | -| Existing consumer was somehow extracting the right header via the wrong constant string | Low | Low | No path in the SDK actually delivers signature verification today. CHANGELOG entry explains | -| Test fixtures contain real-looking secret | Low | Low | Use the probe's `probe-secret-32chars-minimum-length-1` — clearly non-production | - ---- - -## Out-of-Scope Follow-ups (track separately) - -1. **`align-webhooks-resource-with-openapi`** — fix `Webhook` type (`url` → `uri`, `events` → `filters`, add `contentType`, `insecureSsl`, `headers[]`, `status`) -2. **`fix-nfeio-docs-distribuicao-signature`** — file issue/PR against `nfeio-docs` repo to correct the SHA256 claim in distribuicao docs -3. **`sync-openapi-specs-with-docs`** — local `openapi/spec/` is missing several specs that exist in `nfeio-docs/static/api/` (RTC family, Distribuição v2, query API v3s) - ---- - -## Open Questions - -1. Should `validateSignature` also accept the lower-level `(IncomingMessage, secret)` form? Decided: yes, as a secondary helper in Phase 4, not blocking the main fix. -2. Should we add a stricter typed `WebhookHeaders` interface exposing `x-hook-id`, `x-hook-attempts`, `content-md5`? Decided: yes if cheap, but it's additive and can defer. -3. Should we deprecate the standalone `validateSignature` in favor of a request-shaped helper? Decided: no — keep both for flexibility. diff --git a/openspec/changes/fix-webhook-signature-verification/specs/webhook-signature-verification/spec.md b/openspec/changes/fix-webhook-signature-verification/specs/webhook-signature-verification/spec.md deleted file mode 100644 index a602a2c..0000000 --- a/openspec/changes/fix-webhook-signature-verification/specs/webhook-signature-verification/spec.md +++ /dev/null @@ -1,144 +0,0 @@ -# Spec: Webhook Signature Verification - -**Capability**: `webhook-signature-verification` -**Related Change**: `fix-webhook-signature-verification` - ---- - -## MODIFIED Requirements - -### Requirement: Verify Webhook Signature From NFE.io - -**Priority**: CRITICAL -**Rationale**: Webhook signatures are the only mechanism preventing forged delivery to user endpoints. The SDK's verifier MUST correctly match the scheme NFE.io produces in production, MUST be timing-safe, and MUST never throw on adversarial input. - -The SDK MUST provide `WebhooksResource.validateSignature(payload, signature, secret)` that: -- Accepts the raw body as either `Buffer` (preferred, byte-exact) or `string` (encoded as UTF-8) -- Accepts the header value as `string`, `string[]` (Node IncomingMessage shape), or `undefined` -- Returns `true` only when the provided signature matches `HMAC-SHA1(secret, body_bytes)` after stripping the `sha1=` prefix and normalizing case -- Returns `false` for any malformed input, missing input, wrong algorithm prefix, length mismatch, or signature mismatch -- Never throws — including on invalid hex, missing arguments, wrong types, or empty inputs -- Uses `crypto.timingSafeEqual` over equal-length byte buffers (not strings) -- Uses a statically imported `node:crypto` (no runtime `require` lookups) - -#### Scenario: Validate a real signature from NFE.io (live fixture) - -- **Given** a captured webhook delivery from `api.nfse.io` with body `{"action":"ping","webHook":{...}}` (389 bytes) -- **And** the configured secret `probe-secret-32chars-minimum-length-1` -- **And** the header value `sha1=52854606A8B839F24B818CA0DF33CC5A5C7C5406` -- **When** the user calls `nfe.webhooks.validateSignature(bodyBuffer, headerValue, secret)` -- **Then** the method returns `true` - -#### Scenario: Validate the same signature with lowercased hex - -- **Given** the same fixture as above -- **And** the header value `sha1=52854606a8b839f24b818ca0df33cc5a5c7c5406` (lowercased) -- **When** validation runs -- **Then** the method returns `true` (case-insensitive comparison) - -#### Scenario: Accept Buffer and string payloads equivalently - -- **Given** a body string `bodyStr` and its UTF-8 buffer `bodyBuf` -- **And** a valid signature computed over `bodyBuf` -- **When** validation runs with `bodyStr` as payload -- **Then** the method returns `true` -- **When** validation runs with `bodyBuf` as payload -- **Then** the method also returns `true` - -#### Scenario: Accept header value as array (Node IncomingMessage) - -- **Given** a valid signature delivered as `headers['x-hub-signature']` which Node may expose as a single-element array -- **When** validation runs with the array as the signature argument -- **Then** the method returns `true` using the first element - -#### Scenario: Reject a tampered body - -- **Given** a valid signature for body `bodyA` -- **When** validation runs with `bodyA` mutated by one byte -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Reject a signature with the wrong algorithm prefix - -- **Given** a header value `sha256=` -- **When** validation runs -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Reject a signature without the `sha1=` prefix - -- **Given** a header value that is a bare 40-char hex string (no prefix) -- **When** validation runs -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Reject a signature of incorrect length - -- **Given** a header value `sha1=abc` -- **When** validation runs -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Reject a signature with non-hex characters - -- **Given** a header value `sha1=` + 40 non-hex characters -- **When** validation runs -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Reject a missing or empty secret - -- **Given** the secret is `undefined`, `null`, or `''` -- **When** validation runs -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Reject a missing or empty signature header - -- **Given** the signature argument is `undefined`, `null`, `''`, or `[]` -- **When** validation runs -- **Then** the method returns `false` -- **And** does not throw - -#### Scenario: Use constant-time comparison - -- **Given** the implementation computes the expected HMAC and compares against the provided one -- **Then** comparison MUST go through `crypto.timingSafeEqual` over equal-length byte buffers -- **And** MUST NOT short-circuit on the first mismatching byte (e.g., no `===` on hex strings) - -#### Scenario: Use statically imported `node:crypto` - -- **Given** the implementation needs `createHmac` and `timingSafeEqual` -- **Then** they MUST be imported via `import { createHmac, timingSafeEqual } from 'node:crypto'` at module top -- **And** MUST NOT use `(globalThis as any).require`, `require('crypto')`, or any runtime lookup - ---- - -## REMOVED Requirements - -### Requirement: Tolerate Crypto Module Unavailability - -**Reason for removal**: The previous implementation attempted runtime detection of `crypto` and silently returned `false` when unavailable. This was a workaround for an environment NFE.io does not target (the SDK supports Node 18+ exclusively, where `node:crypto` is always present). The defensive lookup masked the real bugs (defects #1–#5 in [design.md](../../design.md)). With static imports, the dependency is type-checked and guaranteed at module load time. - ---- - -## ADDED Requirements - -### Requirement: Document Raw-Body Capture Pattern - -**Priority**: HIGH -**Rationale**: The most common consumer failure mode is computing HMAC over a re-serialized JSON object (`JSON.stringify(req.body)`), whose byte representation differs from what NFE.io signed. Documentation MUST steer users to capture raw bytes at the HTTP boundary. - -The SDK's webhook documentation (README, API.md, JSDoc on `validateSignature`, examples) MUST: -- Show an Express snippet using `express.raw({ type: '*/*' })` (or equivalent middleware) -- Pass `req.body` (Buffer) directly to `validateSignature`, not `JSON.stringify(req.body)` -- Call out in a warning block that re-serializing JSON before validation will fail unpredictably -- Show how to parse the validated buffer back into JSON after verification (`JSON.parse(req.body.toString('utf8'))`) - -#### Scenario: Example uses Buffer, not re-serialized JSON - -- **Given** the documented Express example for webhook validation -- **When** a reader copy-pastes the snippet -- **Then** the snippet uses `express.raw()` middleware (or documents the equivalent for Fastify/Koa/native http) -- **And** passes `req.body` directly to `validateSignature` without `JSON.stringify` -- **And** parses the JSON only after `validateSignature` returns true diff --git a/openspec/changes/fix-webhook-signature-verification/tasks.md b/openspec/changes/fix-webhook-signature-verification/tasks.md deleted file mode 100644 index 293e586..0000000 --- a/openspec/changes/fix-webhook-signature-verification/tasks.md +++ /dev/null @@ -1,46 +0,0 @@ -# Tarefas: Corrigir Verificação de Assinatura de Webhook - -**Change ID**: `fix-webhook-signature-verification` -**Esforço estimado**: 2 dias -**Prioridade**: 🔴 CRÍTICA - ---- - -## Fase 1 — Correção principal - -- [x] **1.1** Substituir `require` em runtime por import estático de `node:crypto` em [src/core/resources/webhooks.ts](../../../src/core/resources/webhooks.ts) -- [x] **1.2** Reescrever o corpo de `validateSignature` conforme [design.md](./design.md): aceitar `Buffer | string` no payload, `string | string[] | undefined` na assinatura, validar prefixo `sha1=`, normalizar case, comparar via `timingSafeEqual` em buffers de 20 bytes -- [x] **1.3** Atualizar JSDoc da função: trocar `X-NFE-Signature` → `X-Hub-Signature`, mostrar exemplo com `express.raw()`, documentar trade-off Buffer vs string -- [x] **1.4** `npm run typecheck && npm run lint` saem com exit code 0 - -## Fase 2 — Testes com fixtures reais - -- [x] **2.1** Criar `tests/fixtures/webhook-signatures.json` com 3 triplets (body, secret, header_value) capturados do probe ao vivo -- [x] **2.2** Adicionar testes positivos em `tests/unit/webhooks-signature.test.ts`: cada fixture valida; round-trip random; case-insensitivity; Buffer e string equivalentes; header como array -- [x] **2.3** Adicionar testes negativos: body adulterado, secret errado, prefixo errado, sem prefixo, comprimento inválido, hex inválido, undefined/empty inputs — todos retornam `false` e NÃO lançam exceção -- [x] **2.4** `npm run test:coverage` mostra 100% de cobertura de branches em `validateSignature`; cobertura geral do recurso ≥ 80% - -## Fase 3 — Propagação de documentação - -- [x] **3.1** Atualizar `docs/API.md`: trocar `x-nfe-signature` → `x-hub-signature`, atualizar snippet pra `express.raw()` + `req.body` Buffer, adicionar nota sobre HMAC-SHA1 + hex maiúsculo + prefixo `sha1=` -- [x] **3.2** Atualizar `examples/all-resources-demo.js` e `examples/real-world-webhooks.js`: nome do header correto + padrão `express.raw()` + `req.body` -- [x] **3.3** Atualizar `skills/nfeio-sdk/SKILL.md` e `skills/nfeio-sdk/references/service-invoices-and-polling.md`: mencionar explicitamente prefixo `sha1=`, hex maiúsculo, preferir `Buffer` -- [x] **3.4** Adicionar entrada destacada em `CHANGELOG.md` (em português, conforme convenção do projeto) sinalizando a correção crítica - -## Fase 4 — Helpers ergonômicos (opcional, pode ficar pra follow-up) - -- [ ] **4.1** Implementar `verifyRequest({ headers, body }, secret): boolean` que compõe sobre `validateSignature` -- [ ] **4.2** Expor tipo `WebhookDeliveryHeaders` com `xHookId`, `xHookAttempts`, `contentMd5`, `xHubSignature` parseados; adicionar helper `parseDeliveryHeaders(headers)` - ---- - -## Definition of Done - -- [x] Todas as tasks das Fases 1–3 marcadas como completas -- [x] `npm run typecheck && npm run lint && npm test -- --run` todos passam (650 testes verdes, 0 falhas) -- [x] `validateSignature` tem 100% de cobertura de branches -- [x] Uma nova execução manual do probe contra o sandbox confirma que os fixtures continuam validando (rodado durante 2.1; 3/3 fixtures bateram com `sha1_hex_upper_prefixed`) -- [x] Entrada no CHANGELOG comitada -- [x] Zero ocorrências acidentais de `X-NFE-Signature` em `src/`, `docs/`, `examples/`, `skills/` (as únicas duas restantes são callouts explícitos do tipo "não use isso") -- [x] Zero ocorrências acidentais de `sha256`/`SHA-256` no contexto de assinatura de webhook (as restantes são teste negativo e disclaimers "not SHA-256") -- [ ] Descrição do PR inclui link pra esta proposta e evidência do probe (pendente — PR ainda não aberta) From 08cc418ad007c2bd82fec7a9de146d63cef7ab45 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 3 Jul 2026 12:35:01 -0300 Subject: [PATCH 4/6] chore: remove obsolete configuration and project context files --- openspec/config.yaml | 20 ---------- openspec/project.md | 89 -------------------------------------------- 2 files changed, 109 deletions(-) delete mode 100644 openspec/config.yaml delete mode 100644 openspec/project.md diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 392946c..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -schema: spec-driven - -# Project context (optional) -# This is shown to AI when creating artifacts. -# Add your tech stack, conventions, style guides, domain knowledge, etc. -# Example: -# context: | -# Tech stack: TypeScript, React, Node.js -# We use conventional commits -# Domain: e-commerce platform - -# Per-artifact rules (optional) -# Add custom rules for specific artifacts. -# Example: -# rules: -# proposal: -# - Keep proposals under 500 words -# - Always include a "Non-goals" section -# tasks: -# - Break tasks into chunks of max 2 hours diff --git a/openspec/project.md b/openspec/project.md deleted file mode 100644 index 51ac115..0000000 --- a/openspec/project.md +++ /dev/null @@ -1,89 +0,0 @@ -# Project Context - -## Purpose -This repository implements the official NFE.io Node.js SDK. It is currently undergoing a major modernization (v2 → v3): migrating from an older JavaScript/callbacks codebase to a TypeScript-first, OpenAPI-generated runtime with a small handwritten DX layer. The goals are: -- Provide a modern, typed, zero-runtime-dependency SDK for Node.js 18+. -- Preserve functional compatibility where practical with the existing v2 API surface. -- Improve developer experience (DX) with typed clients, better error types, retry & rate limiting, and comprehensive tests and docs. - -## Tech Stack -- Primary language: `TypeScript` (>= 5.3) -- Runtime target: `Node.js` (>= 18) -- Test runner: `vitest` -- Bundler/build: `tsup` -- Lint/format: `ESLint` + `Prettier` -- OpenAPI tooling: `openapi-typescript` (generation scripts live under `scripts/`) -- Utilities: `zod` used for runtime validation where necessary - -## Project Conventions - -### Code Style -- Use `strict` TypeScript with no `any` in public APIs. Prefer `unknown` if necessary. -- Exports and public API must have JSDoc comments. -- Format with `prettier` and satisfy `eslint` rules before committing. -- Keep function and file names descriptive; avoid single-letter names. - -### Architecture Patterns -- `src/generated/` is the machine-generated OpenAPI output — DO NOT EDIT. All handwritten code should live outside that folder. -- Handwritten layers: - - `src/core/` or `src/client/`: main `NfeClient` and resource wrappers that provide a pleasant DX. - - `src/runtime/` (or `src/http/`): Fetch-based HTTP client, retry, rate-limiter, and error factory. - - `src/errors/`: typed error hierarchy (AuthenticationError, ValidationError, NfeError, etc.). -- Resource pattern: most endpoints are company-scoped (`company_id`) and follow the same method signatures as v2 where feasible. - -### Testing Strategy -- Unit tests: `vitest` in `tests/unit` — test small modules and runtime helpers. -- Integration tests: `tests/integration` with MSW or local mocks to simulate API behavior (including 202 async flows). -- Coverage target: aim for > 80% for critical modules. -- Before merging: `npm run typecheck && npm run lint && npm test` must pass. - -### Git Workflow -- Branching: use feature branches off `v3` (or the active mainline branch). Name branches `feat/`, `fix/`, `chore/`. -- Commits: follow the conventional commit style used in this repo (examples in `AGENTS.md` and the repo root). Example: `feat(service-invoices): add createAndWait`. -- Pull requests: include tests and update `CHANGELOG.md` when introducing breaking changes. - -### Release & Versioning -- Releases are produced by the `build` pipeline: `npm run build` (which runs generation + bundling). -- Tags and changelog updates must accompany releases. - -### Contribution & PRs -- Add/modify tests alongside implementation. -- Document breaking changes in `CHANGELOG.md` and call them out in the PR description. - -## Domain Context -- This SDK targets the NFe.io API for issuing and managing electronic invoices (service invoices, NF-e, etc.). -- Key domain concepts: - - Service invoices are usually scoped to a `company_id`. - - Creating an invoice may return a 202/201 (async processing) with a `Location` header that must be polled. - - Certificate uploads (company certificate) use a FormData multipart upload and require careful handling. - -## Important Constraints -- `src/generated/` is auto-generated and must never be edited by hand. -- Runtime Node.js version must be >= 18 (native Fetch API assumed). -- Aim for zero runtime dependencies in the published package; allow devDependencies for tooling. -- Maintain backwards-compatible method signatures where possible — breaking changes must be documented. - -## External Dependencies -- Upstream API: `https://api.nfe.io/v1` (production) and any sandbox endpoints used in tests. -- OpenAPI spec files located under `openapi/spec/` — used by generation scripts. -- For certificate uploads, `form-data` may be used in Node where native FormData is insufficient. - -## Useful Files & Commands -- SDK sources: `src/` (handwritten) and `src/generated/` (auto-generated). -- Core scripts: - - `npm run download-spec` — fetch or prepare OpenAPI spec - - `npm run validate-spec` — validate the spec - - `npm run generate` — run OpenAPI generation into `src/generated/` - - `npm run build` — generate + bundle (`tsup`) - - `npm run typecheck` — `tsc --noEmit` - - `npm test` — run tests (vitest) - - `npm run lint` — run ESLint - -## Contacts / Maintainers -- Primary maintainers and owners should be listed in repo `README.md` and the project team documentation. For quick reference, check the `package.json` `author` and the repository settings on GitHub. - ---- - -If you'd like, I can also: -- Add maintainers/contact details into this file. -- Expand any section (e.g., write exact ESLint config, CI steps, or a more detailed testing matrix). From 1e9b73743bade8fe1fd6352883f90f5bfc92d1f3 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 3 Jul 2026 12:36:27 -0300 Subject: [PATCH 5/6] chore: update gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b369ab0..19085d6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ client-python node_modules/ bower_components/ jspm_packages/ - +openspec/ nfeio-docs client-ruby client-php From 88211c98f3537b771708da461a161b6be840b69d Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Fri, 3 Jul 2026 12:44:24 -0300 Subject: [PATCH 6/6] chore(release): v5.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump de versão para 5.1.0 (minor) nos 4 pontos (package.json, VERSION, PACKAGE_VERSION, @version) e datação do CHANGELOG. Conteúdo da release: fix do contrato de webhooks de conta (3ffe83f) — envelope webHook no request/response, tipos AccountWebhook/WebhookEventType, deprecation dos métodos company-scoped (404 confirmado em 3 contas), teste de alinhamento com o spec gerado. --- CHANGELOG.md | 10 ++++++---- package.json | 2 +- src/core/client.ts | 2 +- src/index.ts | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b42a47..0fac8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ Todas as mudanças notáveis neste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). -## [5.1.0] - Não lançado +## [5.1.0] - 2026-07-03 > Correção do contrato de webhooks contra a API real, provado por sonda ao vivo -> (2026-07-02, duas contas). O contrato correto sempre esteve nos specs oficiais +> (2026-07-02/03, três contas). O contrato correto sempre esteve nos specs oficiais > (`openapi/spec/nf-servico-v1.yaml` e equivalentes) — o recurso manuscrito havia > divergido deles. @@ -44,7 +44,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - Métodos company-scoped de webhooks (`list`, `create`, `retrieve`, `update`, `delete`, `test` sobre `/v1/companies/{id}/webhooks`): a rota retorna **404** - na API atual (confirmado em duas contas, 2026-07-02). Use os equivalentes + na API atual (confirmado em três contas, 2026-07-02/03). Use os equivalentes account-scoped. O comportamento não mudou; remoção fica para a próxima major. - Tipos `Webhook` e `WebhookEvent`: shapes que a API real rejeita. Use `AccountWebhook` e `WebhookEventType`. @@ -837,6 +837,8 @@ SDK JavaScript legado com API baseada em callbacks. ## Links -[Unreleased]: https://github.com/nfe/client-nodejs/compare/v3.0.0...HEAD +[Unreleased]: https://github.com/nfe/client-nodejs/compare/v5.1.0...HEAD +[5.1.0]: https://github.com/nfe/client-nodejs/compare/v5.0.0...v5.1.0 +[5.0.0]: https://github.com/nfe/client-nodejs/releases/tag/v5.0.0 [3.0.0]: https://github.com/nfe/client-nodejs/releases/tag/v3.0.0 [2.0.0]: https://github.com/nfe/client-nodejs/releases/tag/v2.0.0 diff --git a/package.json b/package.json index 90b3b34..10a171c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nfe-io", - "version": "5.0.0", + "version": "5.1.0", "description": "Official NFE.io SDK for Node.js - TypeScript native with zero runtime dependencies", "keywords": [ "nfe", diff --git a/src/core/client.ts b/src/core/client.ts index ed64ff3..a65dda4 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1582,7 +1582,7 @@ export default function nfe(apiKey: string | NfeConfig): NfeClient { * Current SDK version * @constant */ -export const VERSION = '5.0.0'; +export const VERSION = '5.1.0'; /** * Supported Node.js version range (semver format) diff --git a/src/index.ts b/src/index.ts index d8d6a47..d5498a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ * ``` * * @module @nfe-io/sdk - * @version 5.0.0 + * @version 5.1.0 * @author NFE.io * @license MIT */ @@ -509,7 +509,7 @@ export const PACKAGE_NAME = '@nfe-io/sdk'; * Current SDK version * @constant */ -export const PACKAGE_VERSION = '5.0.0'; +export const PACKAGE_VERSION = '5.1.0'; /** * NFE.io API version supported by this SDK